using System.Globalization; using System.Text; using LehrerApp.Core.Models; namespace LehrerApp.Core.Services; /// /// Parser für den beobachteten ClassyPlan-Jahresplanexport. Im Gegensatz zum bewusst schmalen /// WebUntis-Parser unterstützt er insbesondere DATE-Ganztagstermine und mehrtägige Ereignisse. /// Er arbeitet absichtlich strikt: Ist ein VEVENT unvollständig oder ungültig, scheitert der /// gesamte Parse, damit ein nachgelagerter Snapshot-Abgleich keine bestehenden Termine löscht. /// public static class AnnualPlanIcsParser { private static readonly TimeZoneInfo BerlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); private readonly record struct Property(string Head, string Value) { public bool IsDate => Head.Contains("VALUE=DATE", StringComparison.OrdinalIgnoreCase); } public static List Parse(string icsText) { var lines = Unfold(icsText); if (!lines.Any(l => l.Equals("BEGIN:VCALENDAR", StringComparison.OrdinalIgnoreCase)) || !lines.Any(l => l.Equals("END:VCALENDAR", StringComparison.OrdinalIgnoreCase))) throw new FormatException("Kein vollständiger iCal-Kalender gefunden."); var result = new List(); Dictionary? current = null; foreach (var line in lines) { if (line.Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase)) { if (current is not null) throw new FormatException("Verschachteltes VEVENT gefunden."); current = new Dictionary(StringComparer.OrdinalIgnoreCase); continue; } if (line.Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase)) { if (current is null) throw new FormatException("END:VEVENT ohne BEGIN:VEVENT gefunden."); result.Add(BuildEvent(current, result.Count + 1)); current = null; continue; } if (current is null) continue; var colon = line.IndexOf(':'); if (colon < 0) continue; var head = line[..colon]; var separator = head.IndexOf(';'); var key = separator < 0 ? head : head[..separator]; if (key.Length > 0) current[key] = new Property(head, line[(colon + 1)..]); } if (current is not null) throw new FormatException("Nicht abgeschlossenes VEVENT gefunden."); if (result.Select(e => e.ExternalId).Distinct(StringComparer.Ordinal).Count() != result.Count) throw new FormatException("Der Jahresplan enthält doppelte iCal-UIDs."); return result; } private static AnnualPlanEvent BuildEvent(Dictionary properties, int number) { var uid = Required(properties, "UID", number).Value.Trim(); if (uid.Length == 0) throw Invalid(number, "UID fehlt"); var startProperty = Required(properties, "DTSTART", number); var endProperty = properties.GetValueOrDefault("DTEND"); DateOnly startDate; DateOnly endDate; TimeOnly? startTime; TimeOnly? endTime; var isAllDay = startProperty.IsDate; if (isAllDay) { startDate = ParseDate(startProperty.Value, number, "DTSTART"); var endExclusive = endProperty == default ? startDate.AddDays(1) : endProperty.IsDate ? ParseDate(endProperty.Value, number, "DTEND") : throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen"); if (endExclusive <= startDate) throw Invalid(number, "DTEND liegt nicht nach DTSTART"); endDate = endExclusive.AddDays(-1); // RFC 5545: DTEND eines Ganztagstermins ist exklusiv. startTime = null; endTime = null; } else { var start = ParseLocalDateTime(startProperty, number, "DTSTART"); var end = endProperty == default ? start : !endProperty.IsDate ? ParseLocalDateTime(endProperty, number, "DTEND") : throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen"); if (end < start) throw Invalid(number, "DTEND liegt vor DTSTART"); startDate = DateOnly.FromDateTime(start); endDate = DateOnly.FromDateTime(end); startTime = TimeOnly.FromDateTime(start); endTime = TimeOnly.FromDateTime(end); } return new AnnualPlanEvent { ExternalId = uid, Title = Text(properties, "SUMMARY"), Description = Text(properties, "DESCRIPTION"), Location = Text(properties, "LOCATION"), StartDate = startDate, EndDate = endDate, StartTime = startTime, EndTime = endTime, IsAllDay = isAllDay, CalendarGroup = Text(properties, "X-GROUPNAME"), Color = Text(properties, "X-COLOR"), Status = Text(properties, "STATUS") is { Length: > 0 } status ? status : "CONFIRMED", Sequence = int.TryParse(Raw(properties, "SEQUENCE"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var sequence) ? sequence : 0, SourceLastModifiedUtc = ParseOptionalUtc(properties.GetValueOrDefault("LAST-MODIFIED"), number), }; } private static Property Required(Dictionary properties, string key, int number) => properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt"); private static string Raw(Dictionary properties, string key) => properties.TryGetValue(key, out var value) ? value.Value : ""; private static string Text(Dictionary properties, string key) => Unescape(Raw(properties, key)); private static DateOnly ParseDate(string value, int number, string field) { if (DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return date; throw Invalid(number, $"ungültiges {field}: {value}"); } private static DateTime ParseLocalDateTime(Property property, int number, string field) { var utc = property.Value.EndsWith('Z'); var value = utc ? property.Value[..^1] : property.Value; var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" }; if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) throw Invalid(number, $"ungültiges {field}: {property.Value}"); return utc ? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone) : DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified); } private static DateTime? ParseOptionalUtc(Property property, int number) { if (property == default || string.IsNullOrWhiteSpace(property.Value)) return null; var local = ParseLocalDateTime(property, number, "LAST-MODIFIED"); return property.Value.EndsWith('Z') ? TimeZoneInfo.ConvertTimeToUtc(local, BerlinTimeZone) : TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), BerlinTimeZone); } private static List Unfold(string text) { var result = new List(); foreach (var line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n')) { if ((line.StartsWith(' ') || line.StartsWith('\t')) && result.Count > 0) result[^1] += line[1..]; else result.Add(line); } return result; } private static string Unescape(string value) { var result = new StringBuilder(value.Length); for (var i = 0; i < value.Length; i++) { if (value[i] != '\\' || i + 1 >= value.Length) { result.Append(value[i]); continue; } var next = value[++i]; result.Append(next switch { 'n' or 'N' => '\n', '\\' => '\\', ',' => ',', ';' => ';', _ => next, }); } return result.ToString(); } private static FormatException Invalid(int number, string message) => new($"Ungültiger Jahresplan-Termin #{number}: {message}."); }