Files
social-media/backend/src/Socialize.Api/Modules/CalendarIntegrations/Services/IcsCalendarParser.cs
Jonathan Bourdon b66c10b681
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
Add calendar integrations and collaboration updates
2026-05-05 15:25:53 -04:00

415 lines
14 KiB
C#

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<ParsedCalendarEvent> Parse(
string content,
DateOnly rangeStart,
DateOnly rangeEnd)
{
ArgumentNullException.ThrowIfNull(content);
List<ParsedCalendarEvent> 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<IcsRawEvent> ReadRawEvents(string content)
{
List<string> lines = UnfoldLines(content).ToList();
for (int index = 0; index < lines.Count; index++)
{
if (!lines[index].Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase))
{
continue;
}
Dictionary<string, List<(Dictionary<string, string> 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<string> 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<string, List<(Dictionary<string, string> 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<string, string> 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<string, List<(Dictionary<string, string> Parameters, string Value)>> properties,
string key,
out (Dictionary<string, string> 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<string, List<(Dictionary<string, string> Parameters, string Value)>> properties,
string key)
{
return TryGetFirst(properties, key, out var value) ? value.Value : null;
}
private static IcsDateTimeValue ParseDateTimeValue(
string value,
Dictionary<string, string> 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<ParsedCalendarEvent> Expand(
IcsRawEvent rawEvent,
DateOnly rangeStart,
DateOnly rangeEnd)
{
TimeSpan duration = GetDuration(rawEvent.Start, rawEvent.End);
IReadOnlyCollection<DateOnly> 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<DateOnly> ExpandStartDates(
IcsRawEvent rawEvent,
DateOnly rangeStart,
DateOnly rangeEnd)
{
if (string.IsNullOrWhiteSpace(rawEvent.RRule))
{
return IsInRange(rawEvent.Start.Date, rangeStart, rangeEnd) ? [rawEvent.Start.Date] : [];
}
Dictionary<string, string> 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<DateOnly> 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),
};
}
}