415 lines
14 KiB
C#
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),
|
|
};
|
|
}
|
|
}
|