Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 38 additions & 5 deletions src/Filee.Engines/Data/ICalendar.cs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,21 @@ private static bool ParseIcsDate(Property property, out DateTime value, out bool
catch (InvalidTimeZoneException) { return null; }
}

// Resolve the offset at an instant after adding the clock part of a DURATION. The local
// wall time may have crossed a daylight-saving transition since DTSTART.
private static TimeSpan? OffsetAtInstant(string id, DateTimeOffset instant, Dictionary<string, ZoneDefinition> zones)
{
if (zones.TryGetValue(id, out var definition) && !definition.HasRule && definition.Transitions.Count > 0)
{
var ordered = definition.Transitions.OrderBy(t => new DateTimeOffset(t.Local, t.From)).ToList();
var found = ordered.LastOrDefault(t => new DateTimeOffset(t.Local, t.From) <= instant);
return found == default ? ordered[0].From : found.To;
}
try { return TimeZoneInfo.FindSystemTimeZoneById(id).GetUtcOffset(instant); }
catch (TimeZoneNotFoundException) { return null; }
catch (InvalidTimeZoneException) { return null; }
}

private static string DateCell(Property? property, Dictionary<string, ZoneDefinition> zones)
{
if (property is null)
Expand All @@ -242,7 +257,9 @@ private static string DateCell(Property? property, Dictionary<string, ZoneDefini
return offset is null ? text : new DateTimeOffset(value, offset.Value).ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
}

private static bool Duration(string text, out TimeSpan duration)
private readonly record struct CalendarDuration(TimeSpan Days, TimeSpan Clock);

private static bool Duration(string text, out CalendarDuration duration)
{
duration = default;
var sign = 1;
Expand All @@ -257,6 +274,8 @@ private static bool Duration(string text, out TimeSpan duration)
var time = false;
var digits = "";
var any = false;
var days = TimeSpan.Zero;
var clock = TimeSpan.Zero;
foreach (var ch in text)
{
if (ch == 'T' && !time)
Expand All @@ -271,7 +290,7 @@ private static bool Duration(string text, out TimeSpan duration)
}
if (digits.Length == 0 || !int.TryParse(digits, out var number))
return false;
duration += ch switch
var part = ch switch
{
'W' when !time => TimeSpan.FromDays(number * 7d),
'D' when !time => TimeSpan.FromDays(number),
Expand All @@ -280,12 +299,16 @@ private static bool Duration(string text, out TimeSpan duration)
'S' when time => TimeSpan.FromSeconds(number),
_ => TimeSpan.MinValue,
};
if (duration < TimeSpan.Zero)
if (part < TimeSpan.Zero)
return false;
if (ch is 'W' or 'D')
days += part;
else
clock += part;
digits = "";
any = true;
}
duration *= sign;
duration = new CalendarDuration(days * sign, clock * sign);
return any && digits.Length == 0;
}

Expand All @@ -300,10 +323,20 @@ public static List<List<string>> ToRows(List<Component> components)
if (end is null && start is not null && ParseIcsDate(start, out var startDate, out var date, out var utc)
&& component.First("DURATION") is { } span && Duration(span.Value, out var duration))
{
var computed = startDate + duration;
// Calendar days keep the local wall time; hours, minutes and seconds are elapsed time (RFC 5545).
var dayEnd = startDate + duration.Days;
var computed = dayEnd + duration.Clock;
var synthetic = new Property(start.Name, computed.ToString(date ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture)
+ (utc && !date ? "Z" : ""), "", start.Parameters);
endCell = DateCell(synthetic, component.Zones);
var zone = start.Parameter("TZID");
if (!date && !utc && duration.Clock != TimeSpan.Zero && zone.Length > 0
&& Offset(zone, dayEnd, component.Zones) is { } dayOffset)
{
var instant = new DateTimeOffset(dayEnd, dayOffset).Add(duration.Clock);
if (OffsetAtInstant(zone, instant, component.Zones) is { } endOffset)
endCell = instant.ToOffset(endOffset).ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
}
}
rows.Add(
[
Expand Down
20 changes: 20 additions & 0 deletions tests/Filee.Engines.Tests/ICalendarTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,26 @@ public async Task Apple_all_day_exclusive_end_duration_and_unknown_properties()
Assert.Equal("2026-11-04", Cell(rows, 3, "End"));
}

[Fact]
public async Task Duration_crossing_daylight_saving_time_keeps_calendar_days_and_elapsed_hours()
{
var customZone = string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:Custom/Spring", "BEGIN:DAYLIGHT",
"DTSTART:20260308T020000", "TZOFFSETFROM:-0500", "TZOFFSETTO:-0400", "END:DAYLIGHT", "END:VTIMEZONE");
var rows = await ReadCalendar(Calendar(customZone,
Event("spring-clock", "DTSTART;TZID=America/New_York:20260308T013000", "DURATION:PT2H"),
Event("spring-day", "DTSTART;TZID=America/New_York:20260308T013000", "DURATION:P1D"),
Event("spring-24h", "DTSTART;TZID=America/New_York:20260308T013000", "DURATION:PT24H"),
Event("spring-mixed", "DTSTART;TZID=America/New_York:20260307T013000", "DURATION:P1DT2H"),
Event("fall-clock", "DTSTART;TZID=America/New_York:20261101T003000", "DURATION:PT2H"),
Event("custom", "DTSTART;TZID=Custom/Spring:20260308T013000", "DURATION:PT2H")));
Assert.Equal("2026-03-08T04:30:00-04:00", Cell(rows, 1, "End"));
Assert.Equal("2026-03-09T01:30:00-04:00", Cell(rows, 2, "End"));
Assert.Equal("2026-03-09T02:30:00-04:00", Cell(rows, 3, "End"));
Assert.Equal("2026-03-08T04:30:00-04:00", Cell(rows, 4, "End"));
Assert.Equal("2026-11-01T01:30:00-05:00", Cell(rows, 5, "End"));
Assert.Equal("2026-03-08T04:30:00-04:00", Cell(rows, 6, "End"));
}

[Fact]
public async Task Outlook_windows_zone_custom_zone_unknown_rules_and_recurrence_instance()
{
Expand Down
Loading