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>
144 lines
6.0 KiB
C#
144 lines
6.0 KiB
C#
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();
|
|
}
|
|
}
|