Skip to content

Commit d1dd281

Browse files
authored
iCalendar Event Creation for Planned Matches using ical.net 5.0.0 (#241)
* Update: iCalendar Event Creation for Planned Matches using `ical.net 5.0.0` - **Calendar events now use UTC date/times:** All iCalendar events for planned matches are generated with UTC date/times. This ensures consistency and avoids ambiguity, as match date/times are already stored in UTC. - **Upgraded to Ical.Net 5.0.0:** The `TournamentManager` project now references `Ical.Net` version 5.0.0, benefiting from new features and bug fixes. ⚠️ **Note:** This upgrade introduces breaking changes. The codebase has been updated to accommodate the new API. - Updated unit tests - Added and updated XML documentation for calendar serialization methods. * Remove code duplications in `TournamentManager.Match.Calendar` * Exclude matches without `PlaennedStart` from context menu "Save match to calendar"
1 parent 432b718 commit d1dd281

File tree

6 files changed

+113
-141
lines changed

6 files changed

+113
-141
lines changed

League/Controllers/Match.cs

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,11 @@ public async Task<IActionResult> Calendar(long id, CancellationToken cancellatio
135135
DescriptionFooter = "\n" + _tenantContext.OrganizationContext.Name + "\n" + _tenantContext.OrganizationContext.HomepageUrl
136136
};
137137
var stream = new MemoryStream();
138-
// RFC5545 sect. 3.4.1: iCal default charset is UTF8.
139-
// Important: no Byte Order Mark (BOM) for Android, Google, Apple
140-
var encoding = new UTF8Encoding(false);
141-
matches.ForEach(m =>
142-
{
143-
// convert to local time
144-
m.PlannedStart = _timeZoneConverter.ToZonedTime(m.PlannedStart)?.DateTimeOffset.DateTime;
145-
m.PlannedEnd = _timeZoneConverter.ToZonedTime(m.PlannedEnd)?.DateTimeOffset.DateTime;
146-
});
147-
calendar.CreateEvents(matches).Serialize(stream, encoding);
148-
stream.Seek(0, SeekOrigin.Begin);
149-
return File(stream, $"text/calendar; charset={encoding.HeaderName}", $"Match_{matches[0].PlannedStart?.ToString("yyyy-MM-dd") ?? string.Empty}_{Guid.NewGuid():N}.ics");
138+
139+
// match date/times have DateTimeKind.Unspecified but are in UTC
140+
calendar.CreateEvents(matches, "UTC").Serialize(stream); // stream is UTF8 without BOM by default
141+
stream.Seek(0, SeekOrigin.Begin);
142+
return File(stream, $"text/calendar; charset={Encoding.UTF8.WebName}", $"Match_{matches[0].PlannedStart?.ToString("yyyy-MM-dd") ?? string.Empty}_{Guid.NewGuid():N}.ics");
150143
}
151144
catch (Exception e)
152145
{
@@ -181,18 +174,11 @@ public async Task<IActionResult> PublicCalendar(long? team, long? round, Cancell
181174
DescriptionFooter = "\n" + _tenantContext.OrganizationContext.Name + "\n" + _tenantContext.OrganizationContext.HomepageUrl
182175
};
183176
var stream = new MemoryStream();
184-
// RFC5545 sect. 3.4.1: iCal default charset is UTF8.
185-
// Important: no Byte Order Mark (BOM) for Android, Google, Apple
186-
var encoding = new UTF8Encoding(false);
187-
matches.ForEach(m =>
188-
{
189-
// convert to local time
190-
m.PlannedStart = _timeZoneConverter.ToZonedTime(m.PlannedStart)?.DateTimeOffset.DateTime;
191-
m.PlannedEnd = _timeZoneConverter.ToZonedTime(m.PlannedEnd)?.DateTimeOffset.DateTime;
192-
});
193-
calendar.CreateEvents(matches).Serialize(stream, encoding);
177+
178+
// match date/times have DateTimeKind.Unspecified but are in UTC
179+
calendar.CreateEvents(matches, "UTC").Serialize(stream); // stream is UTF8 without BOM by default
194180
stream.Seek(0, SeekOrigin.Begin);
195-
return File(stream, $"text/calendar; charset={encoding.HeaderName}", $"Match_{Guid.NewGuid():N}.ics");
181+
return File(stream, $"text/calendar; charset={Encoding.UTF8.WebName}", $"Match_{Guid.NewGuid():N}.ics");
196182
}
197183
catch (Exception e)
198184
{

League/Views/Match/Fixtures.cshtml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,10 @@
124124
<a asp-action=@nameof(League.Controllers.Match.EnterResult) asp-controller=@nameof(League.Controllers.Match) asp-route-tenant="@tenantUrlSegment" asp-route-id=@match.Id tabindex="0" class="dropdown-item"
125125
site-authorize-resource site-resource="@ToMatchEntity(match)"
126126
site-requirement="@League.Authorization.MatchOperations.EnterResult">@Localizer["Enter match result"]</a>
127-
<a asp-action=@nameof(League.Controllers.Match.Calendar) asp-Controller=@nameof(League.Controllers.Match) asp-route-tenant="@tenantUrlSegment" asp-route-id=@match.Id tabindex="0" class="dropdown-item">@Localizer["Save match to calendar"]</a>
127+
@if (match is { PlannedStart: not null, PlannedEnd: not null })
128+
{
129+
<a asp-action=@nameof(League.Controllers.Match.Calendar) asp-Controller=@nameof(League.Controllers.Match) asp-route-tenant="@tenantUrlSegment" asp-route-id=@match.Id tabindex="0" class="dropdown-item">@Localizer["Save match to calendar"]</a>
130+
}
128131
</div>
129132
</div>
130133
<div class="dropdown">
@@ -140,7 +143,10 @@
140143
<a asp-action=@nameof(League.Controllers.Match.EnterResult) asp-controller=@nameof(League.Controllers.Match) asp-route-tenant="@tenantUrlSegment" asp-route-id=@match.Id tabindex="0" class="dropdown-item"
141144
site-authorize-resource site-resource="@ToMatchEntity(match)"
142145
site-requirement="@League.Authorization.MatchOperations.EnterResult">@Localizer["Enter match result"]</a>
143-
<a asp-action=@nameof(League.Controllers.Match.Calendar) asp-Controller=@nameof(League.Controllers.Match) asp-route-tenant="@tenantUrlSegment" asp-route-id=@match.Id tabindex="0" class="dropdown-item">@Localizer["Save match to calendar"]</a>
146+
@if (match is { PlannedStart: not null, PlannedEnd: not null })
147+
{
148+
<a asp-action=@nameof(League.Controllers.Match.Calendar) asp-Controller=@nameof(League.Controllers.Match) asp-route-tenant="@tenantUrlSegment" asp-route-id=@match.Id tabindex="0" class="dropdown-item">@Localizer["Save match to calendar"]</a>
149+
}
144150
</div>
145151
</div>
146152
</td>

TournamentManager/TournamentManager.Tests/Importers/ExcludeDates/InternetCalendarImporterTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ public void Import_InternetCalender_From_Stream(DateTime from, DateTime to, int
3333
var icsFilePath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Assets", "School_Holidays_Bavaria_2024.ics");
3434

3535
var encoding = Encoding.UTF8;
36+
// Test with a stream
3637
var iCalendarStream = new MemoryStream(encoding.GetBytes(File.ReadAllText(icsFilePath, Encoding.UTF8)))
3738
{
3839
Position = 0

TournamentManager/TournamentManager/Importers/ExcludeDates/InternetCalendarImporter.cs

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using Microsoft.Extensions.Logging;
1+
using Ical.Net.DataTypes;
2+
using Microsoft.Extensions.Logging;
23

34
namespace TournamentManager.Importers.ExcludeDates;
45

@@ -36,15 +37,15 @@ public InternetCalendarImporter(string calendarString, string defaultTimeZoneId,
3637
public IEnumerable<ExcludeDateRecord> Import(DateTimePeriod fromToTimePeriod)
3738
{
3839
_iCalendarStreamReader.BaseStream.Position = 0;
39-
var iCal = Ical.Net.Calendar.Load(_iCalendarStreamReader);
40+
var iCal = Ical.Net.Calendar.Load(_iCalendarStreamReader)!;
4041
_logger.LogInformation("Imported {Count} events from iCalendar", iCal.Events.Count);
4142
return Map(iCal, fromToTimePeriod);
4243
}
4344

4445
private IEnumerable<ExcludeDateRecord> Map(Ical.Net.Calendar iCal, DateTimePeriod dateLimits)
4546
{
4647
// small come before big date ranges
47-
foreach (var calendarEvent in iCal.Events.OrderBy(e => e.DtStart.Date).ThenBy(e => e.Duration.Days))
48+
foreach (var calendarEvent in iCal.Events.OrderBy(e => e.DtStart!.Date).ThenBy(e => e.EffectiveDuration.Days))
4849
{
4950
var exclDate = CreateRecord(calendarEvent);
5051
if (dateLimits.Contains(exclDate.Period.Start) || dateLimits.Contains(exclDate.Period.End))
@@ -63,13 +64,13 @@ private ExcludeDateRecord CreateRecord(Ical.Net.CalendarComponents.CalendarEvent
6364
if (calendarEvent.Start == null)
6465
throw new ArgumentException(@$"Could not create {nameof(ExcludeDateRecord)} from {nameof(Ical.Net.CalendarComponents.CalendarEvent)} Start={calendarEvent.Start}, End={calendarEvent.End}, Name={calendarEvent.Description}", nameof(calendarEvent));
6566

66-
calendarEvent.Start.TzId ??= _defaultTimeZoneId;
67+
calendarEvent.Start = calendarEvent.Start.TzId is null ? new CalDateTime(calendarEvent.Start.Date, calendarEvent.Start.Time, _defaultTimeZoneId) : calendarEvent.Start;
6768
var start = calendarEvent.Start.AsUtc;
6869

6970
DateTime end;
7071
if (calendarEvent.End != null)
7172
{
72-
calendarEvent.End.TzId ??= _defaultTimeZoneId;
73+
calendarEvent.End = calendarEvent.End.TzId is null ? new CalDateTime(calendarEvent.End.Date, calendarEvent.End.Time, _defaultTimeZoneId) : calendarEvent.Start;
7374
end = calendarEvent.End.AsUtc.AddSeconds(-1);
7475
}
7576
else
@@ -78,6 +79,6 @@ private ExcludeDateRecord CreateRecord(Ical.Net.CalendarComponents.CalendarEvent
7879
// Swap if necessary
7980
if (start > end) (start, end) = (end, start);
8081

81-
return new ExcludeDateRecord(new DateTimePeriod(start, end), calendarEvent.Summary);
82+
return new ExcludeDateRecord(new DateTimePeriod(start, end), calendarEvent.Summary ?? string.Empty);
8283
}
8384
}

0 commit comments

Comments
 (0)