using System.Globalization; namespace Socialize.Api.Modules.CalendarIntegrations.Services; public record ParsedCalendarEvent( string SourceEventUid, string Title, string? Description, bool IsAllDay, bool IsFloatingTime, DateOnly StartDate, DateOnly EndDate, DateTime? StartLocalDateTime, DateTime? EndLocalDateTime, DateTimeOffset? StartUtc, DateTimeOffset? EndUtc, string? TimeZoneId, string? RecurrenceId, string? Location, string? SourceUrl, DateTimeOffset? SourceLastModifiedAt); internal record IcsDateTimeValue( bool IsAllDay, bool IsFloatingTime, DateOnly Date, DateTime? LocalDateTime, DateTimeOffset? UtcDateTime, string? TimeZoneId); internal sealed record IcsRawEvent( string Uid, string Title, string? Description, IcsDateTimeValue Start, IcsDateTimeValue? End, string? RRule, string? Location, string? SourceUrl, DateTimeOffset? LastModifiedAt); public sealed class IcsCalendarParser { public IReadOnlyCollection Parse( string content, DateOnly rangeStart, DateOnly rangeEnd) { ArgumentNullException.ThrowIfNull(content); List events = []; foreach (IcsRawEvent rawEvent in ReadRawEvents(content)) { events.AddRange(Expand(rawEvent, rangeStart, rangeEnd)); } return events .OrderBy(calendarEvent => calendarEvent.StartDate) .ThenBy(calendarEvent => calendarEvent.Title) .ToArray(); } private static IEnumerable ReadRawEvents(string content) { List lines = UnfoldLines(content).ToList(); for (int index = 0; index < lines.Count; index++) { if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase)) { continue; } Dictionary Parameters, string Value)>> properties = new(StringComparer.OrdinalIgnoreCase); index++; for (; index < lines.Count && !lines[index].Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase); index++) { ParseProperty(lines[index], properties); } if (!TryGetFirst(properties, "DTSTART", out var startProperty)) { continue; } IcsDateTimeValue start = ParseDateTimeValue(startProperty.Value, startProperty.Parameters); IcsDateTimeValue? end = TryGetFirst(properties, "DTEND", out var endProperty) ? ParseDateTimeValue(endProperty.Value, endProperty.Parameters) : null; string uid = TryGetFirst(properties, "UID", out var uidProperty) ? uidProperty.Value : $"{start.Date:yyyyMMdd}:{GetText(properties, "SUMMARY") ?? "calendar-event"}"; yield return new IcsRawEvent( uid, GetText(properties, "SUMMARY") ?? "Untitled event", GetText(properties, "DESCRIPTION"), start, end, GetText(properties, "RRULE"), GetText(properties, "LOCATION"), GetText(properties, "URL"), TryGetFirst(properties, "LAST-MODIFIED", out var lastModified) ? ParseDateTimeValue(lastModified.Value, lastModified.Parameters).UtcDateTime : null); } } private static IEnumerable UnfoldLines(string content) { string? current = null; using StringReader reader = new(content.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n')); while (reader.ReadLine() is { } line) { if ((line.StartsWith(' ') || line.StartsWith('\t')) && current is not null) { current += line[1..]; continue; } if (current is not null) { yield return current; } current = line; } if (current is not null) { yield return current; } } private static void ParseProperty( string line, Dictionary Parameters, string Value)>> properties) { int separatorIndex = line.IndexOf(':', StringComparison.Ordinal); if (separatorIndex < 0) { return; } string nameAndParameters = line[..separatorIndex]; string value = UnescapeText(line[(separatorIndex + 1)..]); string[] nameParts = nameAndParameters.Split(';'); string name = nameParts[0]; Dictionary parameters = new(StringComparer.OrdinalIgnoreCase); foreach (string parameterPart in nameParts.Skip(1)) { int equalsIndex = parameterPart.IndexOf('=', StringComparison.Ordinal); if (equalsIndex > 0) { parameters[parameterPart[..equalsIndex]] = parameterPart[(equalsIndex + 1)..].Trim('"'); } } if (!properties.TryGetValue(name, out var values)) { values = []; properties[name] = values; } values.Add((parameters, value)); } private static string UnescapeText(string value) { return value .Replace("\\n", "\n", StringComparison.OrdinalIgnoreCase) .Replace("\\,", ",", StringComparison.Ordinal) .Replace("\\;", ";", StringComparison.Ordinal) .Replace("\\\\", "\\", StringComparison.Ordinal); } private static bool TryGetFirst( Dictionary Parameters, string Value)>> properties, string key, out (Dictionary Parameters, string Value) value) { if (properties.TryGetValue(key, out var values) && values.Count > 0) { value = values[0]; return true; } value = default; return false; } private static string? GetText( Dictionary Parameters, string Value)>> properties, string key) { return TryGetFirst(properties, key, out var value) ? value.Value : null; } private static IcsDateTimeValue ParseDateTimeValue( string value, Dictionary parameters) { bool isAllDay = parameters.TryGetValue("VALUE", out string? valueType) && valueType.Equals("DATE", StringComparison.OrdinalIgnoreCase); if (isAllDay || value.Length == 8) { DateOnly date = DateOnly.ParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture); return new IcsDateTimeValue(true, false, date, null, null, null); } bool utc = value.EndsWith('Z'); string parseValue = utc ? value[..^1] : value; DateTime local = DateTime.ParseExact(parseValue, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture); if (utc) { DateTimeOffset utcValue = new(DateTime.SpecifyKind(local, DateTimeKind.Utc)); return new IcsDateTimeValue(false, false, DateOnly.FromDateTime(local), local, utcValue, "UTC"); } string? timeZoneId = parameters.GetValueOrDefault("TZID"); bool floating = string.IsNullOrWhiteSpace(timeZoneId); return new IcsDateTimeValue( false, floating, DateOnly.FromDateTime(local), local, TryConvertToUtc(local, timeZoneId), timeZoneId); } private static DateTimeOffset? TryConvertToUtc(DateTime localDateTime, string? timeZoneId) { if (string.IsNullOrWhiteSpace(timeZoneId)) { return null; } try { TimeZoneInfo timeZone = TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); DateTime unspecified = DateTime.SpecifyKind(localDateTime, DateTimeKind.Unspecified); return TimeZoneInfo.ConvertTimeToUtc(unspecified, timeZone); } catch (TimeZoneNotFoundException) { return null; } catch (InvalidTimeZoneException) { return null; } } private static IEnumerable Expand( IcsRawEvent rawEvent, DateOnly rangeStart, DateOnly rangeEnd) { TimeSpan duration = GetDuration(rawEvent.Start, rawEvent.End); IReadOnlyCollection starts = ExpandStartDates(rawEvent, rangeStart, rangeEnd); foreach (DateOnly startDate in starts) { int dayOffset = startDate.DayNumber - rawEvent.Start.Date.DayNumber; IcsDateTimeValue occurrenceStart = Shift(rawEvent.Start, dayOffset); IcsDateTimeValue occurrenceEnd = rawEvent.End is null ? ShiftByDuration(occurrenceStart, duration) : Shift(rawEvent.End, dayOffset); yield return new ParsedCalendarEvent( rawEvent.Uid, rawEvent.Title, rawEvent.Description, occurrenceStart.IsAllDay, occurrenceStart.IsFloatingTime, occurrenceStart.Date, occurrenceEnd.Date, occurrenceStart.LocalDateTime, occurrenceEnd.LocalDateTime, occurrenceStart.UtcDateTime, occurrenceEnd.UtcDateTime, occurrenceStart.TimeZoneId, rawEvent.RRule is null ? null : rawEvent.Uid, rawEvent.Location, rawEvent.SourceUrl, rawEvent.LastModifiedAt); } } private static TimeSpan GetDuration(IcsDateTimeValue start, IcsDateTimeValue? end) { if (end is null) { return start.IsAllDay ? TimeSpan.FromDays(1) : TimeSpan.Zero; } if (start.IsAllDay) { return TimeSpan.FromDays(Math.Max(1, end.Date.DayNumber - start.Date.DayNumber)); } if (start.LocalDateTime.HasValue && end.LocalDateTime.HasValue) { return end.LocalDateTime.Value - start.LocalDateTime.Value; } return TimeSpan.Zero; } private static IReadOnlyCollection ExpandStartDates( IcsRawEvent rawEvent, DateOnly rangeStart, DateOnly rangeEnd) { if (string.IsNullOrWhiteSpace(rawEvent.RRule)) { return IsInRange(rawEvent.Start.Date, rangeStart, rangeEnd) ? [rawEvent.Start.Date] : []; } Dictionary rule = rawEvent.RRule .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(part => part.Split('=', 2)) .Where(parts => parts.Length == 2) .ToDictionary(parts => parts[0], parts => parts[1], StringComparer.OrdinalIgnoreCase); string frequency = rule.GetValueOrDefault("FREQ", "DAILY"); int interval = int.TryParse(rule.GetValueOrDefault("INTERVAL"), out int parsedInterval) ? Math.Max(1, parsedInterval) : 1; int? count = int.TryParse(rule.GetValueOrDefault("COUNT"), out int parsedCount) ? parsedCount : null; DateOnly? until = TryParseUntil(rule.GetValueOrDefault("UNTIL")); List dates = []; DateOnly current = rawEvent.Start.Date; for (int occurrence = 1; occurrence <= (count ?? 500); occurrence++) { if (until.HasValue && current > until.Value) { break; } if (current > rangeEnd) { break; } if (IsInRange(current, rangeStart, rangeEnd)) { dates.Add(current); } current = frequency.ToUpperInvariant() switch { "YEARLY" => current.AddYears(interval), "MONTHLY" => current.AddMonths(interval), "WEEKLY" => current.AddDays(7 * interval), _ => current.AddDays(interval), }; } return dates; } private static DateOnly? TryParseUntil(string? value) { if (string.IsNullOrWhiteSpace(value)) { return null; } string dateValue = value.EndsWith('Z') ? value[..^1] : value; if (dateValue.Length >= 8 && DateOnly.TryParseExact(dateValue[..8], "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out DateOnly date)) { return date; } return null; } private static bool IsInRange(DateOnly value, DateOnly rangeStart, DateOnly rangeEnd) { return value >= rangeStart && value <= rangeEnd; } private static IcsDateTimeValue Shift(IcsDateTimeValue value, int dayOffset) { return value with { Date = value.Date.AddDays(dayOffset), LocalDateTime = value.LocalDateTime?.AddDays(dayOffset), UtcDateTime = value.UtcDateTime?.AddDays(dayOffset), }; } private static IcsDateTimeValue ShiftByDuration(IcsDateTimeValue value, TimeSpan duration) { if (value.IsAllDay) { return value with { Date = value.Date.AddDays(Math.Max(1, (int)duration.TotalDays)) }; } return value with { Date = value.LocalDateTime.HasValue ? DateOnly.FromDateTime(value.LocalDateTime.Value.Add(duration)) : value.Date, LocalDateTime = value.LocalDateTime?.Add(duration), UtcDateTime = value.UtcDateTime?.Add(duration), }; } }