using LehrerApp.Core.Models; namespace LehrerApp.Core.Services; public sealed class UntisDiffResult { /// SubstitutionEntry-Kandidaten (Id ist immer neu vergeben — der Aufrufer entscheidet über /// ISubstitutionEntryRepository.GetByExternalId, ob ein vorhandener Eintrag aktualisiert /// statt dupliziert werden muss; siehe UntisSyncService). public List SubstitutionsToSave { get; init; } = []; public List SnapshotToSave { get; init; } = []; /// Verschwundene, bereits als Ausfall verarbeitete Zeilen — werden aus dem Snapshot entfernt, /// damit derselbe Ausfall nicht bei jedem weiteren Poll erneut erkannt wird. public List SnapshotIdsToDelete { get; init; } = []; /// ExternalIds automatisch erzeugter SubstitutionEntry-Zeilen, die NICHT (mehr) gebraucht /// werden — z.B. weil eine früher erkannte Abweichung sich (durch einen Bugfix oder eine /// erneute Zuordnung) als nicht mehr abweichend herausstellt, oder eine zuvor als fehlend /// gemeldete Stunde jetzt wieder im Feed auftaucht. Nutzer-Feedback: "Es ist immer noch so" /// (nachdem der eigentliche Vergleichsfehler bereits behoben war) — Ursache: einmal erzeugte /// automatische Einträge wurden nie wieder entfernt, selbst wenn der Vergleich sie beim /// nächsten Poll nicht mehr als Abweichung einstufte; sie blieben als Karteileichen stehen. /// Existiert kein Eintrag mit dieser ExternalId, ist das Löschen ein no-op (siehe UntisSyncService). public List SubstitutionExternalIdsToDelete { get; init; } = []; } /// /// Stufe 2 des WebUntis-Abgleichs: ein neuer iCal-Abruf gegen den zuletzt gespeicherten /// Schnappschuss (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden"). /// Framework-frei wie — nimmt nur einfache Objekte/Listen /// entgegen, kein Datenbankzugriff. /// public class UntisDiffService { public const int DefaultLookaheadDays = 14; public UntisDiffResult Diff(List newEvents, List previousSnapshot, List confirmedMappings, DateOnly today, int lookaheadDays = DefaultLookaheadDays, List? existingSupervisionDuties = null, HashSet? freeDates = null) { var previousByUid = previousSnapshot.ToDictionary(s => s.Uid); // GroupBy statt direktem ToDictionary: robust gegen mehrere Mappings für denselben Slot // (z.B. Altdaten vor einem Bugfix) - die zuletzt angelegte gewinnt, statt mit einer // ArgumentException abzubrechen und dadurch den gesamten Poll (Timer-Callback) zu killen. var mappingByWeekdayStart = confirmedMappings .Where(m => m.Confirmed && (m.Kind == SubstitutionKind.Lesson ? m.PeriodNumber is not null : m.AfterPeriod is not null)) .GroupBy(m => (m.Weekday, m.StartTime)) .ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First()); // Regelmäßige, ohnehin schon jede Woche im Stundenplan sichtbare Aufsichten (siehe // SupervisionDuty/Einstellungen "Aufsichten") - eine bestätigte Supervision-Zuordnung, die // KEINER davon entspricht, ist per Definition eine zusätzliche/Vertretungsaufsicht und soll // deshalb selbst als reguläres Vorkommnis (nicht erst bei Abweichung) im Plan auftauchen. var regularDutyKeys = (existingSupervisionDuties ?? []) .Select(d => (d.Weekday, d.AfterPeriod)) .ToHashSet(); var substitutions = new List(); var snapshotToSave = new List(); var externalIdsToDelete = new List(); var newEventsByUid = newEvents.ToDictionary(e => e.Uid); foreach (var evt in newEvents) { snapshotToSave.Add(new UntisSnapshotEntry { Id = previousByUid.TryGetValue(evt.Uid, out var existing) ? existing.Id : Guid.NewGuid(), Uid = evt.Uid, Date = evt.Date, StartTime = evt.StartTime, EndTime = evt.EndTime, Summary = evt.Summary, Location = evt.Location, Description = evt.Description, Status = evt.Status, LastSeenAt = DateTime.UtcNow, }); if (!mappingByWeekdayStart.TryGetValue((evt.Weekday, evt.StartTime), out var mapping)) continue; if (string.Equals(evt.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase)) { substitutions.Add(mapping.Kind == SubstitutionKind.Lesson ? BuildCancelledLesson(evt.Date, mapping, evt.Uid) : BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Laut WebUntis abgesagt.")); continue; } if (mapping.Kind == SubstitutionKind.Lesson) { if (DeviationReason(evt, mapping) is { } reason) substitutions.Add(BuildChangedLesson(evt, mapping, reason)); else externalIdsToDelete.Add(evt.Uid); // war ggf. vorher fälschlich als Vertretung erkannt } else if (!regularDutyKeys.Contains((mapping.Weekday, mapping.AfterPeriod!.Value))) { // Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht // doch der Sync nur so halb Sinn" - ohne passende reguläre Aufsicht IST dieses // Vorkommnis selbst schon die meldenswerte Vertretung, nicht erst eine Abweichung // davon. substitutions.Add(BuildSupervisionNote(evt.Date, mapping, evt.Uid, "Zusätzliche Aufsicht laut WebUntis.")); } else { externalIdsToDelete.Add(evt.Uid); // jetzt als reguläre Aufsicht erkannt, keine Meldung mehr nötig } } // Aufsichten: weiterhin rein reaktiv gegen den letzten Snapshot (kein fester wöchentlicher // Anspruch, siehe "Zusätzliche Aufsicht" oben - ein Verschwinden ist nur meldenswert, wenn // die Aufsicht vorher tatsächlich einmal gesehen wurde). var snapshotIdsToDelete = new List(); foreach (var previous in previousSnapshot) { if (newEventsByUid.ContainsKey(previous.Uid)) continue; if (previous.Date < today || previous.Date > today.AddDays(lookaheadDays)) continue; if (!mappingByWeekdayStart.TryGetValue((previous.Date.DayOfWeek, previous.StartTime), out var mapping)) continue; if (mapping.Kind != SubstitutionKind.Supervision) continue; var vanishedId = BuildVanishedExternalId(previous.Date, mapping); substitutions.Add(BuildSupervisionNote(previous.Date, mapping, vanishedId, "Laut WebUntis entfallen oder übernommen.")); snapshotIdsToDelete.Add(previous.Id); } // Unterrichtsstunden: zusätzlich AKTIV prüfen, ob für jedes vom Feed bereits abgedeckte // künftige Datum dieses Wochentags ein passender Termin existiert - rein reaktives // Schnappschuss-Diffing (oben) erkennt nur Termine, die zwischen zwei Abrufen // VERSCHWINDEN, nicht solche, die schon beim allerersten Abruf nie im Feed auftauchten // (Nutzer-Feedback: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht // nicht im Plan [...] die Klasse ist dort weg, der Unterricht wird definitiv nicht // stattfinden" - WebUntis hatte diesen Ausfall von Anfang an nie als Termin gelistet, es // gab also nie einen Schnappschuss-Eintrag, der hätte "verschwinden" können). Begrenzt auf // das vom Feed tatsächlich abgedeckte Zeitfenster (jüngstes gesehenes Datum), damit nicht // Tage jenseits des vom Feed veröffentlichten Horizonts fälschlich als Ausfall gelten, und // um Ferien/Feiertage bereinigt (freeDates), an denen WebUntis ohnehin keinen Termin führt. if (newEvents.Count > 0) { var maxKnownDate = newEvents.Max(e => e.Date); var horizonEnd = maxKnownDate < today.AddDays(lookaheadDays) ? maxKnownDate : today.AddDays(lookaheadDays); var eventDates = newEvents.Select(e => (e.Date, e.StartTime)).ToHashSet(); var freeDateSet = freeDates ?? []; foreach (var mapping in mappingByWeekdayStart.Values.Where(m => m.Kind == SubstitutionKind.Lesson)) { for (var date = today; date <= horizonEnd; date = date.AddDays(1)) { if (date.DayOfWeek != mapping.Weekday) continue; if (freeDateSet.Contains(date)) continue; if (eventDates.Contains((date, mapping.StartTime))) { // War evtl. an einem früheren Poll (noch) als fehlend gemeldet - jetzt // wieder im Feed vorhanden, die alte Meldung räumt sich damit selbst ab. externalIdsToDelete.Add(BuildMissingExternalId(date, mapping)); continue; } substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping))); } } } return new UntisDiffResult { SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave, SnapshotIdsToDelete = snapshotIdsToDelete, SubstitutionExternalIdsToDelete = externalIdsToDelete, }; } // Nutzer-Feedback: "Ich habe das aufgelöst und den Mathematik E-Kurs ausgewählt [...] Diese // manuelle Verknüpfung ist aber jetzt scheinbar vergessen" — Ursache: ClassToken wird beim // Bestätigen kompakt ohne Leerzeichen gespeichert ("10a;10b;10c"), WebUntis trennt die // Klassen in DESCRIPTION aber mit "; " (Semikolon + Leerzeichen), z.B. "10a; 10b; 10c; // Gastro HED". Ein erster Fix entfernte Leerzeichen nur auf der evt.Description-Seite — blieb // aber weiterhin falsch, wenn mapping.ClassToken selbst ein eingebettetes Leerzeichen enthält // (z.B. wenn die reale DESCRIPTION mehrere Klassen NICHT mit ";", sondern mit "," trennt: // ExtractClassTokens fand dann kein ";" und behandelte "10a, 10b, 10c" als EIN Token mit // Leerzeichen/Kommas statt drei getrennte - ein Vergleich gegen die leerzeichenbereinigte // Description konnte dieses Token dann NIE mehr finden, unabhängig von der Auswahl im Dialog). // Fix: beide Seiten symmetrisch von Leerzeichen befreien (gleiches Prinzip wie // UntisMatchingService.Normalize für den Gruppennamen-Abgleich), plus ExtractClassTokens // akzeptiert jetzt sowohl ";" als auch "," als Trennzeichen zwischen Klassen. // // Trotzdem meldet der Nutzer weiterhin falsche Vertretungen für genau diese Art Muster // ("Immer die Kurse mit Lerngruppen, die aus mehreren Klassen zusammengesetzt sind") - der // genaue Grund lässt sich ohne Einblick in die echten DESCRIPTION-Werte aus seinem Feed nicht // mehr blind erraten. Statt eines weiteren ungetesteten Rateversuchs schreibt eine abweichende // Vertretung deshalb jetzt den GENAUEN Vergleich (roher evt.Summary/evt.Description gegen den // gespeicherten mapping.Summary/ClassToken) in ihre eigene Beschreibung - der Nutzer kann die // Ursache dann direkt im Stundenplan ablesen, ohne die iCal-URL erneut teilen zu müssen. private static string? DeviationReason(UntisIcsEvent evt, UntisSlotMapping mapping) { if (evt.Summary != mapping.Summary) return $"Fach weicht ab: WebUntis meldet „{evt.Summary ?? "(leer)"}“, erwartet war „{mapping.Summary ?? "(leer)"}“."; if (!RemoveWhitespace(evt.Description).Contains(RemoveWhitespace(mapping.ClassToken))) return $"Klasse weicht ab: WebUntis-Beschreibung „{evt.Description}“ enthält nicht den erwarteten Klassen-Token „{mapping.ClassToken}“."; return null; } private static string RemoveWhitespace(string value) => new(value.Where(c => !char.IsWhiteSpace(c)).ToArray()); private static SubstitutionEntry BuildCancelledLesson(DateOnly date, UntisSlotMapping mapping, string externalId) => new() { Date = date, Kind = SubstitutionKind.Cancelled, PeriodNumber = mapping.PeriodNumber, Description = "Automatisch über WebUntis-Abgleich erkannt.", ExternalId = externalId, }; private static SubstitutionEntry BuildChangedLesson(UntisIcsEvent evt, UntisSlotMapping mapping, string reason) => new() { Date = evt.Date, Kind = SubstitutionKind.Lesson, PeriodNumber = mapping.PeriodNumber, GroupId = mapping.GroupId, GroupLabel = mapping.ClassToken, Description = $"WebUntis: {evt.Summary ?? "?"} · {evt.Location} (regulär: {mapping.Summary ?? "?"}) — {reason}", ExternalId = evt.Uid, }; // Aufsichten haben keinen TimetableSlot im Hintergrund (siehe SubstitutionEntry.Kind-Doc) - // deshalb Kind=Supervision statt Cancelled, auch wenn der Termin verschwunden ist. private static SubstitutionEntry BuildSupervisionNote(DateOnly date, UntisSlotMapping mapping, string externalId, string reason) => new() { Date = date, Kind = SubstitutionKind.Supervision, AfterPeriod = mapping.AfterPeriod, Description = $"Automatisch über WebUntis-Abgleich erkannt: {reason}", ExternalId = externalId, }; // Ein verschwundener Termin hat keine eigene UID mehr im neuen Fetch - ein aus Slot+Datum // abgeleiteter Ersatzschlüssel dient weiter als Idempotenz-Schlüssel für spätere Polls. private static string BuildVanishedExternalId(DateOnly date, UntisSlotMapping mapping) => $"vanished-{mapping.Id}-{date:yyyyMMdd}"; // Für eine von Anfang an fehlende Stunde gibt es nie eine echte iCal-UID - ein aus // Slot+Datum abgeleiteter Schlüssel macht wiederholte Polls trotzdem idempotent (Update // statt Duplikat), unabhängig vom Snapshot-Stand. private static string BuildMissingExternalId(DateOnly date, UntisSlotMapping mapping) => $"missing-{mapping.Id}-{date:yyyyMMdd}"; }