using System.Globalization; using System.Text; namespace LehrerApp.Core.Services; /// Ein einzelner Termin (VEVENT) aus einer geparsten iCal-Datei — z.B. aus dem /// WebUntis-Stundenplan-Export (TODO.md, "WebUntis-iCal-Abgleich"). public class UntisIcsEvent { public string Uid { get; set; } = ""; public DateOnly Date { get; set; } public DayOfWeek Weekday => Date.DayOfWeek; public TimeOnly StartTime { get; set; } public TimeOnly EndTime { get; set; } public string? Summary { get; set; } public string Location { get; set; } = ""; public string Description { get; set; } = ""; public string Status { get; set; } = "CONFIRMED"; } /// /// Bewusst kein vollständiger RFC-5545-Parser, sondern genau der Ausschnitt, den der reale /// WebUntis-Export tatsächlich nutzt (verifiziert an einem echten Export, siehe Planungsdokument): /// flache VEVENT-Liste ohne RRULE-Wiederholung, ohne Line-Folding, ohne VALARM. `DTSTART;TZID=...` /// wird als lokale Wanduhrzeit gelesen (die App kennt ohnehin nur "lokale Zeit" der Schule, siehe /// PeriodScheduleService) — ein `...Z`-Suffix (UTC) wird defensiv unterstützt, auch wenn im realen /// Export nicht beobachtet. Termine mit unbekanntem DTSTART-Format werden übersprungen statt die /// gesamte Verarbeitung abzubrechen (gleiche Fail-soft-Haltung wie EventApplier in LehrerApp.Sync). /// public static class IcsParser { private static readonly TimeZoneInfo BerlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); public static List Parse(string icsText) { var lines = Unfold(icsText); var events = new List(); Dictionary? current = null; foreach (var line in lines) { if (line == "BEGIN:VEVENT") { current = []; continue; } if (line == "END:VEVENT") { if (current is not null && TryBuildEvent(current, out var evt)) events.Add(evt); current = null; continue; } if (current is null) continue; var (key, paramsPart, value) = SplitPropertyLine(line); if (key.Length == 0) continue; current[key] = (paramsPart, value); } return events; } // RFC 5545 Line-Folding: eine Fortsetzungszeile beginnt mit Leerzeichen/Tab und gehört zur // Vorzeile — im realen WebUntis-Export nicht beobachtet (Zeilen deutlich unter dem 75-Oktett- // Limit), aber defensiv unterstützt, falls sich der Export einmal ändert. private static List Unfold(string icsText) { var rawLines = icsText.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n'); var result = new List(); foreach (var raw in rawLines) { if ((raw.StartsWith(' ') || raw.StartsWith('\t')) && result.Count > 0) result[^1] += raw[1..]; else result.Add(raw); } return result; } // "KEY;PARAM=VAL;PARAM2=VAL2:VALUE" — der erste unescapte Doppelpunkt trennt Wert vom Rest. private static (string Key, string Params, string Value) SplitPropertyLine(string line) { var colonIndex = line.IndexOf(':'); if (colonIndex < 0) return ("", "", ""); var head = line[..colonIndex]; var value = line[(colonIndex + 1)..]; var semiIndex = head.IndexOf(';'); return semiIndex < 0 ? (head, "", value) : (head[..semiIndex], head[(semiIndex + 1)..], value); } private static bool TryBuildEvent(Dictionary props, out UntisIcsEvent evt) { evt = new UntisIcsEvent(); if (!props.TryGetValue("UID", out var uid) || string.IsNullOrWhiteSpace(uid.Value)) return false; evt.Uid = uid.Value; if (!props.TryGetValue("DTSTART", out var dtStart) || !TryParseLocalDateTime(dtStart, out var start)) return false; evt.Date = DateOnly.FromDateTime(start); evt.StartTime = TimeOnly.FromDateTime(start); if (props.TryGetValue("DTEND", out var dtEnd) && TryParseLocalDateTime(dtEnd, out var end)) evt.EndTime = TimeOnly.FromDateTime(end); evt.Summary = props.TryGetValue("SUMMARY", out var summary) && !string.IsNullOrWhiteSpace(summary.Value) ? Unescape(summary.Value) : null; evt.Location = props.TryGetValue("LOCATION", out var location) ? Unescape(location.Value) : ""; evt.Description = props.TryGetValue("DESCRIPTION", out var description) ? Unescape(description.Value) : ""; evt.Status = props.TryGetValue("STATUS", out var status) && !string.IsNullOrWhiteSpace(status.Value) ? status.Value : "CONFIRMED"; return true; } // "TZID=Europe/Berlin:20260817T075000" (lokale Wanduhrzeit) oder "20260817T075000Z" (UTC). private static bool TryParseLocalDateTime((string Params, string Value) prop, out DateTime local) { local = default; var value = prop.Value; var isUtc = value.EndsWith('Z'); var digits = isUtc ? value[..^1] : value; if (!DateTime.TryParseExact(digits, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) return false; local = isUtc ? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone) : parsed; return true; } private static string Unescape(string value) { var sb = new StringBuilder(value.Length); for (var i = 0; i < value.Length; i++) { if (value[i] == '\\' && i + 1 < value.Length) { var next = value[i + 1]; sb.Append(next switch { ';' => ';', ',' => ',', 'n' or 'N' => '\n', '\\' => '\\', _ => next }); i++; } else sb.Append(value[i]); } return sb.ToString(); } }