WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle
Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry. Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für Stunden auf, die real unverändert sind — wird in einem Folge-Commit untersucht, deshalb vorerst auf diesem Branch statt main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>Ein einzelner Termin (VEVENT) aus einer geparsten iCal-Datei — z.B. aus dem
|
||||
/// WebUntis-Stundenplan-Export (TODO.md, "WebUntis-iCal-Abgleich").</summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public static class IcsParser
|
||||
{
|
||||
private static readonly TimeZoneInfo BerlinTimeZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin");
|
||||
|
||||
public static List<UntisIcsEvent> Parse(string icsText)
|
||||
{
|
||||
var lines = Unfold(icsText);
|
||||
var events = new List<UntisIcsEvent>();
|
||||
Dictionary<string, (string Params, string Value)>? 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<string> Unfold(string icsText)
|
||||
{
|
||||
var rawLines = icsText.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n');
|
||||
var result = new List<string>();
|
||||
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<string, (string Params, string Value)> 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user