From 4eb4d0a9460d11b6fce6dbaef9ed26400c3298c1 Mon Sep 17 00:00:00 2001 From: Sebastian Hedtrich Date: Sun, 23 Aug 2026 13:30:15 +0200 Subject: [PATCH 1/2] =?UTF-8?q?WIP=20(unstable):=20WebUntis-iCal-Abgleich?= =?UTF-8?q?=20f=C3=BCr=20Vertretungen/Ausf=C3=A4lle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LehrerApp.Core/Interfaces/IRepositories.cs | 16 + LehrerApp.Core/Models/Planning.cs | 6 + LehrerApp.Core/Models/UntisSync.cs | 69 +++ LehrerApp.Core/Services/IcsParser.cs | 143 ++++++ LehrerApp.Core/Services/UntisDiffService.cs | 187 ++++++++ .../Services/UntisMatchingService.cs | 235 ++++++++++ LehrerApp.Data.Tests/RepositoryTests.cs | 73 +++ LehrerApp.Data/LiteDbContext.cs | 2 + .../Repositories/AllRepositories.cs | 22 + LehrerApp.Desktop.Tests/Fakes.cs | 27 ++ .../SettingsViewModelTests.cs | 4 + .../TimetableViewModelTests.cs | 139 +++++- .../UntisMappingReviewDialogViewModelTests.cs | 183 ++++++++ .../UntisSyncServiceTests.cs | 285 ++++++++++++ .../WebUntisSettingsServiceTests.cs | 89 ++++ LehrerApp.Desktop/AppBootstrapper.cs | 20 + .../Services/UntisSyncService.cs | 187 ++++++++ .../Services/WebUntisSettingsService.cs | 93 ++++ .../ViewModels/Planning/TimetableViewModel.cs | 39 +- .../UntisMappingReviewDialogViewModel.cs | 188 ++++++++ .../ViewModels/Settings/SettingsViewModel.cs | 78 +++- .../Views/Planning/TimetableView.axaml | 15 +- .../Planning/UntisMappingReviewDialog.axaml | 88 ++++ .../UntisMappingReviewDialog.axaml.cs | 27 ++ .../Views/Settings/SettingsView.axaml | 35 ++ .../Views/Settings/SettingsView.axaml.cs | 21 + LehrerApp.Tests/IcsParserTests.cs | 150 ++++++ LehrerApp.Tests/UntisDiffServiceTests.cs | 432 ++++++++++++++++++ LehrerApp.Tests/UntisMatchingServiceTests.cs | 248 ++++++++++ TODO.md | 240 ++++++++++ 30 files changed, 3333 insertions(+), 8 deletions(-) create mode 100644 LehrerApp.Core/Models/UntisSync.cs create mode 100644 LehrerApp.Core/Services/IcsParser.cs create mode 100644 LehrerApp.Core/Services/UntisDiffService.cs create mode 100644 LehrerApp.Core/Services/UntisMatchingService.cs create mode 100644 LehrerApp.Desktop.Tests/UntisMappingReviewDialogViewModelTests.cs create mode 100644 LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs create mode 100644 LehrerApp.Desktop.Tests/WebUntisSettingsServiceTests.cs create mode 100644 LehrerApp.Desktop/Services/UntisSyncService.cs create mode 100644 LehrerApp.Desktop/Services/WebUntisSettingsService.cs create mode 100644 LehrerApp.Desktop/ViewModels/Planning/UntisMappingReviewDialogViewModel.cs create mode 100644 LehrerApp.Desktop/Views/Planning/UntisMappingReviewDialog.axaml create mode 100644 LehrerApp.Desktop/Views/Planning/UntisMappingReviewDialog.axaml.cs create mode 100644 LehrerApp.Tests/IcsParserTests.cs create mode 100644 LehrerApp.Tests/UntisDiffServiceTests.cs create mode 100644 LehrerApp.Tests/UntisMatchingServiceTests.cs diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index 0d01f90..f4dcbc1 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -124,9 +124,25 @@ public interface ISubstitutionEntryRepository { List GetAll(); List GetByDate(DateOnly date); + /// Für den WebUntis-Abgleich (idempotentes Update statt Duplikat je iCal-UID). + SubstitutionEntry? GetByExternalId(string externalId); void Save(SubstitutionEntry entry); void Delete(Guid id); } +/// Zuletzt bekannter Zustand aller WebUntis-iCal-Termine, für den Abgleich bei jedem Abruf. +public interface IUntisSnapshotRepository +{ + List GetAll(); + void Save(UntisSnapshotEntry entry); + void Delete(Guid id); +} +/// Vom Nutzer bestätigte Zuordnungen WebUntis-Wochenmuster → LearningGroup. +public interface IUntisSlotMappingRepository +{ + List GetAll(); + void Save(UntisSlotMapping mapping); + void Delete(Guid id); +} public interface IDocumentationRepository { List GetByStudent(Guid studentId); diff --git a/LehrerApp.Core/Models/Planning.cs b/LehrerApp.Core/Models/Planning.cs index 6fcb6cc..79b931b 100644 --- a/LehrerApp.Core/Models/Planning.cs +++ b/LehrerApp.Core/Models/Planning.cs @@ -210,6 +210,12 @@ public class SubstitutionEntry /// Bezeichnung (bei SpecialAssignment, z.B. "Ausflug ins Museum", "Berufsmesse"). public string Description { get; set; } = ""; public string? Notes { get; set; } + /// Gesetzt, wenn dieser Eintrag automatisch aus dem WebUntis-iCal-Abgleich entstanden ist — + /// trägt die stabile iCal-UID des auslösenden Termins. Macht wiederholte Abgleich-Läufe + /// idempotent (Update statt Duplikat, siehe ISubstitutionEntryRepository.GetByExternalId) und + /// unterscheidet automatisch erzeugte von von Hand eingetragenen Ausnahmen. Bei Handeinträgen + /// (SubstitutionEntryDialogViewModel) bleibt es null. + public string? ExternalId { get; set; } } /// diff --git a/LehrerApp.Core/Models/UntisSync.cs b/LehrerApp.Core/Models/UntisSync.cs new file mode 100644 index 0000000..b2f727e --- /dev/null +++ b/LehrerApp.Core/Models/UntisSync.cs @@ -0,0 +1,69 @@ +namespace LehrerApp.Core.Models; + +/// +/// Zuletzt bekannter Zustand eines einzelnen WebUntis-iCal-Termins (ein VEVENT) — die lokale +/// "Sicherungskopie", gegen die jeder neue Abruf verglichen wird, um Änderungen zu erkennen +/// (Nutzer-Feedback: "Abgleich mit einem lokalen Backup, um Änderungen zu finden"). +/// ist die stabile iCal-UID des Termins (pro Wochen-Slot über das ganze Schuljahr gleich) und +/// dient als fachlicher Schlüssel für den Abgleich — bewusst kein [BsonId] darauf, da +/// LehrerApp.Core absichtlich frei von LiteDB/Avalonia-Abhängigkeiten bleibt (siehe CLAUDE.md); +/// die Suche nach läuft stattdessen über eine gefilterte Repository-Abfrage. +/// +public class UntisSnapshotEntry +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Uid { get; set; } = ""; + public DateOnly Date { get; set; } + 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"; + public DateTime LastSeenAt { get; set; } = DateTime.UtcNow; +} + +/// +/// Vom Nutzer bestätigte Zuordnung eines regulären WebUntis-Wochenmusters (Wochentag + Uhrzeit + +/// Fach-Kürzel + Klassen-Token aus der Beschreibung) zu einer bestehenden +/// () oder — für Termine ohne Klassenbezug, z.B. Aufsichten — +/// zu einer festen Pause (, +/// statt /; Nutzer-Feedback: "Zwei Termine sind +/// meine Aufsichten, die nicht zugeordnet werden können [...] vom Zeitraster und von der Dauer her +/// könnten die erfasst werden" — Aufsichten liegen in Pausen, nicht auf einem Unterrichtsstunden- +/// Zeitraster, brauchen also einen eigenen Auflösungsweg statt PeriodNumber). Nur bestätigte +/// ( true) Zuordnungen lösen automatisch geschriebene +/// -Einträge aus (Nutzer-Feedback: die erstmalige Zuordnung ist +/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine +/// Bestätigung, bevor sie aktiv wird; siehe UntisMappingReviewDialog). +/// +public class UntisSlotMapping +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public DayOfWeek Weekday { get; set; } + public TimeOnly StartTime { get; set; } + public string? Summary { get; set; } + public string ClassToken { get; set; } = ""; + public SubstitutionKind Kind { get; set; } = SubstitutionKind.Lesson; + /// Nur bei gesetzt. + public Guid? GroupId { get; set; } + /// Erste/primäre Stunde, zum Bestätigungszeitpunkt über PeriodScheduleService aufgelöst (siehe + /// UntisMatchingService) — hier gespeichert, damit UntisDiffService selbst framework-frei + /// bleibt und keine eigene Uhrzeit→Stunde-Auflösung braucht. Nur bei + /// gesetzt. + public int? PeriodNumber { get; set; } + /// ALLE Stunden, die dieser WebUntis-Termin überdeckt (siehe UntisSlotMatch.CoveredPeriods) — + /// bei einer Doppelstunde mehr als eine. Entscheidet, welche TimetableSlots als "durch WebUntis + /// bestätigt" gelten (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, und trotzdem + /// erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" — ohne diese Liste blieb + /// die zweite Stunde einer Doppelstunde immer "unzugeordnet", weil WebUntis dafür nur einen + /// einzigen, am Anfang beginnenden Termin meldet). Nur bei + /// gesetzt. + public List CoveredPeriods { get; set; } = []; + /// Nur bei gesetzt — wie bei + /// , ebenfalls zum Bestätigungszeitpunkt aufgelöst + /// (die Pause direkt vor der WebUntis-Startzeit, 0 = vor der 1. Stunde). + public int? AfterPeriod { get; set; } + public bool Confirmed { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/LehrerApp.Core/Services/IcsParser.cs b/LehrerApp.Core/Services/IcsParser.cs new file mode 100644 index 0000000..c12e708 --- /dev/null +++ b/LehrerApp.Core/Services/IcsParser.cs @@ -0,0 +1,143 @@ +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(); + } +} diff --git a/LehrerApp.Core/Services/UntisDiffService.cs b/LehrerApp.Core/Services/UntisDiffService.cs new file mode 100644 index 0000000..86c070d --- /dev/null +++ b/LehrerApp.Core/Services/UntisDiffService.cs @@ -0,0 +1,187 @@ +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; } = []; +} + +/// +/// 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 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 (HasDeviated(evt, mapping)) + substitutions.Add(BuildChangedLesson(evt, mapping)); + } + 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.")); + } + } + + // 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))) continue; + + substitutions.Add(BuildCancelledLesson(date, mapping, BuildMissingExternalId(date, mapping))); + } + } + } + + return new UntisDiffResult + { + SubstitutionsToSave = substitutions, SnapshotToSave = snapshotToSave, + SnapshotIdsToDelete = snapshotIdsToDelete, + }; + } + + // 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 reiner Teilstring-Vergleich schlug für JEDE kombinierte/differenzierte + // Gruppe (mehr als ein Klassen-Token) strukturell fehl, unabhängig von der tatsächlich + // gewählten Gruppe. Fix: beide Seiten vor dem Vergleich von Leerzeichen befreien (gleiches + // Prinzip wie UntisMatchingService.Normalize für den Gruppennamen-Abgleich). + private static bool HasDeviated(UntisIcsEvent evt, UntisSlotMapping mapping) => + evt.Summary != mapping.Summary || !RemoveWhitespace(evt.Description).Contains(mapping.ClassToken); + + 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) => 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 ?? "?"})", + 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}"; +} diff --git a/LehrerApp.Core/Services/UntisMatchingService.cs b/LehrerApp.Core/Services/UntisMatchingService.cs new file mode 100644 index 0000000..3a52b6f --- /dev/null +++ b/LehrerApp.Core/Services/UntisMatchingService.cs @@ -0,0 +1,235 @@ +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +/// Ein aus vielen Wochen-Vorkommen abgeleitetes reguläres WebUntis-Wochenmuster +/// (Nutzer-Feedback: "eine fuzzy logic, die erst mal die regulären Fächer meiner ical meinem +/// Stundenplan in der App zuordnet"). +public sealed class UntisWeeklyPattern +{ + public DayOfWeek Weekday { get; init; } + public TimeOnly StartTime { get; init; } + public TimeOnly EndTime { get; init; } + public string? Summary { get; init; } + /// Klassen-Token(s) aus DESCRIPTION, Lehrkraft-Kürzel bereits abgetrennt — bei kombinierten + /// Gruppen mehrere Einträge (z.B. ["10a", "10b", "10c", "Gastro"]). + public List ClassTokens { get; init; } = []; + public int OccurrenceCount { get; init; } +} + +public sealed class UntisSlotMatch +{ + public required UntisWeeklyPattern Pattern { get; init; } + /// Erste/primäre Stunde, gesetzt wenn das Muster einen Klassenbezug hat (Unterrichtsstunde) — + /// entspricht CoveredPeriods[0]. + public int? PeriodNumber { get; init; } + /// ALLE Stunden, die dieser WebUntis-Termin überdeckt — bei einer Doppelstunde meldet WebUntis + /// EINEN Termin über beide Stundenzeiten hinweg (z.B. 07:50–09:20 für Stunde 1+2), während der + /// Stundenplan der App dafür ZWEI TimetableSlot-Einträge (Stunde 1 UND Stunde 2) haben kann. + /// Ohne diese Liste würde die zweite Stunde nie als "zugeordnet" gelten, selbst nachdem der + /// Nutzer das Muster bestätigt hat (Nutzer-Feedback: "ich habe aber jetzt alles zugeordnet, + /// und trotzdem erhalte ich die Warnung, dass 16 Stunden ohne Untis-Zuordnung sind"). + public List CoveredPeriods { get; init; } = []; + /// Gesetzt, wenn das Muster KEINEN Klassenbezug hat (z.B. Aufsicht/Springstunde) — die Pause + /// direkt vor der Startzeit (0 = vor der 1. Stunde), siehe UntisSlotMapping.AfterPeriod. + public int? AfterPeriod { get; init; } + public bool IsSupervisionCandidate => Pattern.ClassTokens.Count == 0; + public Guid? SuggestedGroupId { get; init; } + public bool IsConfident { get; init; } +} + +public sealed class UntisMatchResult +{ + public List Matches { get; init; } = []; + /// Vorhandene TimetableSlots, zu denen sich kein passendes WebUntis-Wochenmuster finden ließ + /// (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt"). + public List UnmatchedTimetableSlots { get; init; } = []; +} + +/// +/// Stufe 1 des WebUntis-Abgleichs (siehe TODO.md): aus einem vollen iCal-Abruf (typischerweise +/// ein Schuljahr an Einzelterminen, siehe IcsParser) das reguläre Wochenmuster ableiten und gegen +/// den in der App gepflegten Stundenplan (TimetableSlot/LearningGroup) abgleichen. Framework-frei +/// wie AttendanceBalanceService — reine Objekte rein, reine Objekte raus, ohne Datenbank- oder +/// HTTP-Zugriff, dadurch ohne Mocking testbar. +/// +public class UntisMatchingService +{ + // Kleine Toleranz gegen Minuten-Rundungsdifferenzen zwischen dem konfigurierten Stundenraster + // und den tatsächlichen WebUntis-Zeiten. + private const int StartTimeToleranceMinutes = 3; + + public UntisMatchResult BuildMatches(List events, List groups, + List timetableSlots, PeriodScheduleService periodSchedule) + { + var teacherToken = DetectTeacherToken(events); + var patterns = BuildWeeklyPatterns(events, teacherToken); + + var matches = new List(); + foreach (var pattern in patterns) + { + // Kein Klassenbezug (z.B. Aufsicht/Springstunde, Nutzer-Feedback: "Zwei Termine sind + // meine Aufsichten, die nicht zugeordnet werden können") - liegt typischerweise in + // einer Pause, nicht auf einer Unterrichtsstunden-Startzeit, deshalb eigener, + // grundsätzlich immer auflösbarer Weg statt ResolvePeriodNumber. + if (pattern.ClassTokens.Count == 0) + { + matches.Add(new UntisSlotMatch { Pattern = pattern, AfterPeriod = ResolveAfterPeriod(pattern.StartTime, periodSchedule) }); + continue; + } + + var coveredPeriods = ResolveCoveredPeriods(pattern.StartTime, pattern.EndTime, periodSchedule); + var periodNumber = coveredPeriods.Count > 0 ? coveredPeriods[0] : ResolvePeriodNumber(pattern.StartTime, periodSchedule); + var (groupId, confident) = ResolveGroup(pattern.ClassTokens, periodNumber, groups, timetableSlots); + matches.Add(new UntisSlotMatch + { + Pattern = pattern, PeriodNumber = periodNumber, CoveredPeriods = coveredPeriods, + SuggestedGroupId = groupId, IsConfident = confident, + }); + } + + var matchedSlotKeys = matches + .Where(m => m.SuggestedGroupId is not null) + .SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : []) + .Select(period => (m.Pattern.Weekday, period, m.SuggestedGroupId!.Value))) + .ToHashSet(); + var unmatchedSlots = timetableSlots + .Where(s => !matchedSlotKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId))) + .ToList(); + + return new UntisMatchResult { Matches = matches, UnmatchedTimetableSlots = unmatchedSlots }; + } + + /// Häufigstes letztes Wort in DESCRIPTION über alle Termine — bei einem persönlichen Feed + /// konstant das eigene Kürzel, bewusst nicht hartkodiert (siehe Planungsdokument). + private static string? DetectTeacherToken(List events) => + events + .Select(e => LastToken(e.Description)) + .Where(t => t is not null) + .GroupBy(t => t) + .OrderByDescending(g => g.Count()) + .Select(g => g.Key) + .FirstOrDefault(); + + private static string? LastToken(string description) + { + var parts = description.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length == 0 ? null : parts[^1]; + } + + private static List BuildWeeklyPatterns(List events, string? teacherToken) + { + var withTokens = events.Select(e => new + { + Event = e, + ClassTokens = ExtractClassTokens(e.Description, teacherToken), + }); + + var patterns = new List(); + foreach (var group in withTokens.GroupBy(x => (x.Event.Weekday, x.Event.StartTime, x.Event.EndTime))) + { + var modal = group + .GroupBy(x => (x.Event.Summary, ClassTokens: string.Join(";", x.ClassTokens))) + .OrderByDescending(g => g.Count()) + .First(); + + patterns.Add(new UntisWeeklyPattern + { + Weekday = group.Key.Weekday, + StartTime = group.Key.StartTime, + EndTime = group.Key.EndTime, + Summary = modal.Key.Summary, + ClassTokens = modal.Key.ClassTokens.Length == 0 + ? [] : modal.Key.ClassTokens.Split(';').ToList(), + OccurrenceCount = modal.Count(), + }); + } + return patterns; + } + + // "10a; 10b; 10c; Gastro HED" -> ["10a", "10b", "10c", "Gastro"] (Lehrkraft-Token entfernt). + private static List ExtractClassTokens(string description, string? teacherToken) + { + var withoutTeacher = teacherToken is not null && description.EndsWith(teacherToken) + ? description[..^teacherToken.Length].TrimEnd() + : description; + if (string.IsNullOrWhiteSpace(withoutTeacher)) return []; + return withoutTeacher.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + } + + private static int? ResolvePeriodNumber(TimeOnly startTime, PeriodScheduleService periodSchedule) + { + var exact = periodSchedule.Periods.FirstOrDefault(p => p.Start == startTime); + if (exact is not null) return exact.PeriodNumber; + + var closest = periodSchedule.Periods + .Select(p => (Period: p, DiffMinutes: Math.Abs((p.Start.ToTimeSpan() - startTime.ToTimeSpan()).TotalMinutes))) + .Where(t => t.DiffMinutes <= StartTimeToleranceMinutes) + .OrderBy(t => t.DiffMinutes) + .FirstOrDefault(); + return closest.Period?.PeriodNumber; + } + + // Alle konfigurierten Stunden, die vollständig innerhalb [start, end) liegen — bei einer + // einzelnen Stunde genau eine, bei einer von WebUntis zu einem Termin zusammengefassten + // Doppelstunde entsprechend zwei (siehe UntisSlotMatch.CoveredPeriods). + private static List ResolveCoveredPeriods(TimeOnly start, TimeOnly end, PeriodScheduleService periodSchedule) + { + var tolerance = TimeSpan.FromMinutes(StartTimeToleranceMinutes); + return periodSchedule.Periods + .Where(p => p.Start.ToTimeSpan() >= start.ToTimeSpan() - tolerance + && p.End.ToTimeSpan() <= end.ToTimeSpan() + tolerance) + .OrderBy(p => p.PeriodNumber) + .Select(p => p.PeriodNumber) + .ToList(); + } + + // Die Pause direkt vor startTime: die zuletzt endende konfigurierte Stunde davor, 0 (vor der + // 1. Stunde) falls keine liegt - anders als ResolvePeriodNumber immer auflösbar, da eine + // Pause per Definition zwischen/vor Stunden liegt statt exakt auf einer Startzeit. + private static int ResolveAfterPeriod(TimeOnly startTime, PeriodScheduleService periodSchedule) => + periodSchedule.Periods + .Where(p => p.End <= startTime) + .OrderByDescending(p => p.End) + .Select(p => (int?)p.PeriodNumber) + .FirstOrDefault() ?? 0; + + private static (Guid? GroupId, bool Confident) ResolveGroup(List classTokens, int? periodNumber, + List groups, List timetableSlots) + { + if (classTokens.Count == 0) return (null, false); + + var candidates = classTokens + .Select(token => ResolveSingleGroup(token, groups)) + .Where(g => g is not null) + .Select(g => g!) + .Distinct() + .ToList(); + + if (candidates.Count == 1) return (candidates[0].Id, true); + if (candidates.Count == 0) return (null, false); + + // Mehrere Klassen im selben Termin (kombinierte/differenzierte Gruppen) — die Gruppe + // bevorzugen, die an dieser Stelle bereits einen TimetableSlot hat. + if (periodNumber is { } period) + { + var withExistingSlot = candidates + .Where(g => timetableSlots.Any(s => s.GroupId == g.Id && s.PeriodNumber == period)) + .ToList(); + if (withExistingSlot.Count == 1) return (withExistingSlot[0].Id, true); + } + return (null, false); // mehrdeutig - der Nutzer entscheidet im Review-Dialog + } + + private static LearningGroup? ResolveSingleGroup(string token, List groups) + { + var exact = groups.FirstOrDefault(g => string.Equals(g.Name, token, StringComparison.OrdinalIgnoreCase)); + if (exact is not null) return exact; + + var normalizedToken = Normalize(token); + return groups.FirstOrDefault(g => Normalize(g.Name) == normalizedToken); + } + + private static string Normalize(string value) => + new(value.Where(c => !char.IsWhiteSpace(c)).Select(char.ToLowerInvariant).ToArray()); +} diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 63331c7..498cd86 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -955,6 +955,79 @@ public sealed class RepositoryTests Assert.Equal("Vertretung 8a", result[0].Description); } + [Fact] + public void SubstitutionEntryRepository_GetByExternalId_FindetAutomatischErzeugtenEintrag() + { + using var db = NewInMemoryContext(); + var repo = new SubstitutionEntryRepository(db); + repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 12), Kind = SubstitutionKind.Cancelled, PeriodNumber = 3, ExternalId = "38818-2004584-2004587" }); + repo.Save(new SubstitutionEntry { Date = new DateOnly(2026, 3, 13), Kind = SubstitutionKind.Cancelled, PeriodNumber = 4 }); + + var result = repo.GetByExternalId("38818-2004584-2004587"); + + Assert.NotNull(result); + Assert.Equal(new DateOnly(2026, 3, 12), result!.Date); + Assert.Null(repo.GetByExternalId("unbekannt")); + } + + // ── UntisSnapshotRepository / UntisSlotMappingRepository ───────────────── + + [Fact] + public void UntisSnapshotRepository_SaveUndGetAll_RoundTrip() + { + using var db = NewInMemoryContext(); + var repo = new UntisSnapshotRepository(db); + repo.Save(new UntisSnapshotEntry + { + Uid = "38926-2013812-2013815", Date = new DateOnly(2026, 8, 17), + StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20), + Summary = "SOL", Location = "Medien", Description = "10c HED", + }); + + var result = repo.GetAll(); + + Assert.Single(result); + Assert.Equal("38926-2013812-2013815", result[0].Uid); + Assert.Equal("SOL", result[0].Summary); + } + + [Fact] + public void UntisSnapshotRepository_Save_AktualisiertVorhandenenEintrag() + { + using var db = NewInMemoryContext(); + var repo = new UntisSnapshotRepository(db); + var entry = new UntisSnapshotEntry { Uid = "38926-2013812-2013815", Location = "Medien" }; + repo.Save(entry); + + entry.Location = "A1"; + entry.Status = "CANCELLED"; + repo.Save(entry); + + var result = Assert.Single(repo.GetAll()); + Assert.Equal("A1", result.Location); + Assert.Equal("CANCELLED", result.Status); + } + + [Fact] + public void UntisSlotMappingRepository_SaveDeleteUndGetAll_RoundTrip() + { + using var db = NewInMemoryContext(); + var repo = new UntisSlotMappingRepository(db); + var groupId = Guid.NewGuid(); + var mapping = new UntisSlotMapping + { + Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), + Summary = "SOL", ClassToken = "10c", GroupId = groupId, Confirmed = true, + }; + repo.Save(mapping); + + Assert.Single(repo.GetAll()); + + repo.Delete(mapping.Id); + + Assert.Empty(repo.GetAll()); + } + // ── WorkTaskRepository ──────────────────────────────────────────────────── [Fact] diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index e32f74d..a0ae4bb 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -66,6 +66,8 @@ public class LiteDbContext : IDisposable public ILiteCollection SchoolHolidays => _db.GetCollection("school_holidays"); public ILiteCollection SupervisionDuties => _db.GetCollection("supervision_duties"); public ILiteCollection SubstitutionEntries => _db.GetCollection("substitution_entries"); + public ILiteCollection UntisSnapshotEntries => _db.GetCollection("untis_snapshot_entries"); + public ILiteCollection UntisSlotMappings => _db.GetCollection("untis_slot_mappings"); public ILiteCollection TrashedItems => _db.GetCollection("trash"); public void Checkpoint() => _db.Checkpoint(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 863757a..5d83b38 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -742,6 +742,8 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR public List GetAll() => db.SubstitutionEntries.FindAll().OrderBy(e => e.Date).ToList(); public List GetByDate(DateOnly date) => db.SubstitutionEntries.Find(e => e.Date == date).ToList(); + public SubstitutionEntry? GetByExternalId(string externalId) => + db.SubstitutionEntries.FindOne(e => e.ExternalId == externalId); public void Save(SubstitutionEntry entry) { db.SubstitutionEntries.Upsert(entry); @@ -754,6 +756,26 @@ public class SubstitutionEntryRepository(LiteDbContext db) : ISubstitutionEntryR } } +// Bewusst kein db.OnChange hier (anders als sonst überall): UntisSnapshotEntry ist eine rein +// lokale, hochfrequente Abgleich-Zwischenablage (jeder Poll aktualisiert alle Zeilen), deren +// Sync nur Rauschen erzeugen würde. UntisSlotMapping ist an die pro Gerät hinterlegte +// WebUntis-URL gebunden (WebUntisSettingsService, wie SyncSettingsService nicht synchronisiert) +// und deshalb ebenfalls sinnvollerweise lokal — jedes Gerät bestätigt seine Zuordnung einmal +// selbst über den Review-Dialog. +public class UntisSnapshotRepository(LiteDbContext db) : IUntisSnapshotRepository +{ + public List GetAll() => db.UntisSnapshotEntries.FindAll().ToList(); + public void Save(UntisSnapshotEntry entry) => db.UntisSnapshotEntries.Upsert(entry); + public void Delete(Guid id) => db.UntisSnapshotEntries.Delete(id); +} + +public class UntisSlotMappingRepository(LiteDbContext db) : IUntisSlotMappingRepository +{ + public List GetAll() => db.UntisSlotMappings.FindAll().ToList(); + public void Save(UntisSlotMapping mapping) => db.UntisSlotMappings.Upsert(mapping); + public void Delete(Guid id) => db.UntisSlotMappings.Delete(id); +} + public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository { public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index fd7d549..641d45f 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -31,6 +31,14 @@ public static class TestSupport new HttpClient(), new FakeLessons(), new FakeGroups([]), new FakeSubjects([]), new FakeCompetencyDomains(), new FakeAlternativeLessonPaths([])); + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. + public static WebUntisSettingsService BuildWebUntisSettingsService() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettings-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new WebUntisSettingsService(tempPath); + } + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. public static SyncSettingsService BuildSyncSettingsService() { @@ -422,10 +430,29 @@ public class FakeSubstitutionEntries : ISubstitutionEntryRepository public void Add(SubstitutionEntry e) => _all.Add(e); public List GetAll() => _all.ToList(); public List GetByDate(DateOnly date) => _all.Where(e => e.Date == date).ToList(); + public SubstitutionEntry? GetByExternalId(string externalId) => _all.FirstOrDefault(e => e.ExternalId == externalId); public void Save(SubstitutionEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); } +public class FakeUntisSnapshots : IUntisSnapshotRepository +{ + private readonly List _all = []; + public void Add(UntisSnapshotEntry e) => _all.Add(e); + public List GetAll() => _all.ToList(); + public void Save(UntisSnapshotEntry entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } + public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); +} + +public class FakeUntisSlotMappings : IUntisSlotMappingRepository +{ + private readonly List _all = []; + public void Add(UntisSlotMapping m) => _all.Add(m); + public List GetAll() => _all.ToList(); + public void Save(UntisSlotMapping mapping) { _all.RemoveAll(m => m.Id == mapping.Id); _all.Add(mapping); } + public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id); +} + public class FakeWorkTasks : IWorkTaskRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 94d820a..4a0b99a 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -34,6 +34,7 @@ public sealed class SettingsViewModelTests holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), @@ -289,6 +290,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); @@ -314,6 +316,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); @@ -343,6 +346,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); diff --git a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs index c3a9545..8a2612f 100644 --- a/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/TimetableViewModelTests.cs @@ -1,5 +1,6 @@ using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Planning; using LehrerApp.Desktop.ViewModels.Settings; using Xunit; @@ -12,7 +13,8 @@ public sealed class TimetableViewModelTests FakeTimetableSlots slots, FakeGroups groups, FakeSchoolHolidays? holidays = null, FakeSubjects? subjects = null, FakeLessons? lessons = null, FakeExams? exams = null, SchoolCalendarSettingsService? calendarSettings = null, - FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null) + FakeSupervisionDuties? supervisionDuties = null, FakeSubstitutionEntries? substitutions = null, + FakeUntisSlotMappings? untisMappings = null, WebUntisSettingsService? untisSettings = null) { // Bewusst kein "using": SchoolCalendarSettingsService liest den Pfad nur bei Bedarf // (SetState), das Verzeichnis muss über die Lebensdauer des ViewModels bestehen bleiben. @@ -25,7 +27,8 @@ public sealed class TimetableViewModelTests holidays ?? new FakeSchoolHolidays(), calendarSettings ?? new SchoolCalendarSettingsService(tempPath), new PublicHolidayService(), new SchoolYearService(), - supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries()); + supervisionDuties ?? new FakeSupervisionDuties(), substitutions ?? new FakeSubstitutionEntries(), + untisMappings ?? new FakeUntisSlotMappings(), untisSettings ?? TestSupport.BuildWebUntisSettingsService()); } /// Nächstes Datum ab (inkl.) , das auf einen Wochentag Mo-Fr fällt — @@ -727,6 +730,31 @@ public sealed class TimetableViewModelTests Assert.Equal("Vertretung für Fr. Schmidt", mondayCell.SupervisionLocation); } + [Fact] + public void Load_ZusaetzlicheAufsichtInDerFolgewoche_ErscheintImWochenrasterNachNavigation() + { + // Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der + // Sync nur so halb Sinn" - dieselbe Anzeige-Infrastruktur wie beim Ausfall oben, hier für + // eine automatisch erkannte zusätzliche Aufsicht ohne reguläre SupervisionDuty. + var dateNextWeek = DateInCurrentWeek(DayOfWeek.Monday).AddDays(7); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = dateNextWeek, Kind = SubstitutionKind.Supervision, AfterPeriod = 1, + Description = "Automatisch über WebUntis-Abgleich erkannt: Zusätzliche Aufsicht laut WebUntis.", + }); + var vm = BuildViewModel(new FakeTimetableSlots(), new FakeGroups([]), substitutions: substitutions); + + Assert.DoesNotContain(vm.WeekItems, c => c.IsSupervisionRow && c.Text.Contains("n. 1.")); + + vm.NextWeekCommand.Execute(null); + + var label = vm.WeekItems.Single(c => c.IsSupervisionRow && c.Text.Contains("n. 1.")); + var mondayCell = vm.WeekItems.SkipWhile(c => c != label).Skip(1).First(); + Assert.True(mondayCell.IsSubstitutionSupervision); + Assert.Contains("Zusätzliche Aufsicht", mondayCell.SupervisionLocation); + } + [Fact] public void Load_Wochenraster_VertretungsstundeUeberschreibtNormaleAnzeige() { @@ -930,6 +958,38 @@ public sealed class TimetableViewModelTests Assert.Equal("6a auf Klassenfahrt", cell.Topic); } + [Fact] + public void Load_AusfallInDerFolgewoche_ErscheintImWochenrasterNachNavigation() + { + // Nutzer-Feedback: "Nächste Woche habe ich eine Stunde Ausfall [...] wäre es doch auch + // schön, wenn das im Stundenplan für die nächste Woche irgendwie erkenntlich ist" - genau + // der Weg, den ein automatisch von WebUntis erkannter Ausfall nimmt (derselbe + // SubstitutionEntry-Mechanismus wie bei Handeinträgen), hier erstmals mit WeekOffset != 0 + // geprüft. + var subject = new Subject { Name = "Naturwissenschaften", ShortName = "NAT" }; + var group = new LearningGroup { Name = "6a", SubjectId = subject.Id }; + var dateNextWeek = DateInCurrentWeek(DayOfWeek.Thursday).AddDays(7); + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Thursday, PeriodNumber = 3 }); + var substitutions = new FakeSubstitutionEntries(); + substitutions.Add(new SubstitutionEntry + { + Date = dateNextWeek, Kind = SubstitutionKind.Cancelled, PeriodNumber = 3, + Description = "Automatisch über WebUntis-Abgleich erkannt.", + }); + var vm = BuildViewModel(slots, new FakeGroups([group]), subjects: new FakeSubjects([subject]), substitutions: substitutions); + + // Aktuelle Woche: an dieser Stelle regulär, noch kein Ausfall sichtbar. + var currentWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3); + Assert.False(currentWeekCell.IsCancelled); + + vm.NextWeekCommand.Execute(null); + + var nextWeekCell = vm.WeekItems.Single(c => c.IsSlotCell && c.Weekday == DayOfWeek.Thursday && c.PeriodNumber == 3); + Assert.True(nextWeekCell.IsCancelled); + Assert.Equal("Automatisch über WebUntis-Abgleich erkannt.", nextWeekCell.Topic); + } + [Fact] public void Load_Wochenraster_KeinAusfall_ZeigtNormaleStunde() { @@ -1041,4 +1101,79 @@ public sealed class TimetableViewModelTests Assert.Single(vm.UpcomingExams); } + + // ── WebUntis-Abweichung ─────────────────────────────────────────────────── + + [Fact] + public void Load_UntisAbgleichNichtAktiviert_ZeigtKeineAbweichung() + { + var group = new LearningGroup { Name = "10c" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + + var vm = BuildViewModel(slots, new FakeGroups([group])); + + Assert.False(vm.HasUntisMismatch); + } + + [Fact] + public void Load_UntisAktivSlotOhneBestaetigteZuordnung_ZeigtAbweichung() + { + var group = new LearningGroup { Name = "10c" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var untisSettings = TestSupport.BuildWebUntisSettingsService(); + untisSettings.SetIcalUrl("https://example.org/ical"); + untisSettings.SetEnabled(true); + + var vm = BuildViewModel(slots, new FakeGroups([group]), untisSettings: untisSettings); + + Assert.True(vm.HasUntisMismatch); + Assert.Contains("1 Stundenplan-Eintrag", vm.UntisMismatchLabel); + } + + [Fact] + public void Load_UntisAktivMitBestaetigterZuordnung_ZeigtKeineAbweichung() + { + var group = new LearningGroup { Name = "10c" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + var mappings = new FakeUntisSlotMappings(); + mappings.Add(new UntisSlotMapping + { + Weekday = DayOfWeek.Monday, PeriodNumber = 1, GroupId = group.Id, Confirmed = true, + }); + var untisSettings = TestSupport.BuildWebUntisSettingsService(); + untisSettings.SetIcalUrl("https://example.org/ical"); + untisSettings.SetEnabled(true); + + var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings); + + Assert.False(vm.HasUntisMismatch); + } + + [Fact] + public void Load_DoppelstundenZuordnungBestaetigtBeideTimetableSlots() + { + // Regression: "ich habe aber jetzt alles zugeordnet, und trotzdem erhalte ich die + // Warnung, dass 16 Stunden ohne Untis-Zuordnung sind" - eine WebUntis-Doppelstunde + // bestätigt beide zugehörigen TimetableSlots, nicht nur den ersten. + var group = new LearningGroup { Name = "10c" }; + var slots = new FakeTimetableSlots(); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 1 }); + slots.Add(new TimetableSlot { GroupId = group.Id, Weekday = DayOfWeek.Monday, PeriodNumber = 2 }); + var mappings = new FakeUntisSlotMappings(); + mappings.Add(new UntisSlotMapping + { + Weekday = DayOfWeek.Monday, PeriodNumber = 1, CoveredPeriods = [1, 2], + GroupId = group.Id, Confirmed = true, + }); + var untisSettings = TestSupport.BuildWebUntisSettingsService(); + untisSettings.SetIcalUrl("https://example.org/ical"); + untisSettings.SetEnabled(true); + + var vm = BuildViewModel(slots, new FakeGroups([group]), untisMappings: mappings, untisSettings: untisSettings); + + Assert.False(vm.HasUntisMismatch); + } } diff --git a/LehrerApp.Desktop.Tests/UntisMappingReviewDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/UntisMappingReviewDialogViewModelTests.cs new file mode 100644 index 0000000..82f2aa2 --- /dev/null +++ b/LehrerApp.Desktop.Tests/UntisMappingReviewDialogViewModelTests.cs @@ -0,0 +1,183 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; +using LehrerApp.Desktop.ViewModels.Planning; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class UntisMappingReviewDialogViewModelTests +{ + private static string BuildTempPath() + { + var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untisreview-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + private static UntisSyncService BuildUntisSyncService(FakeUntisSlotMappings mappings) => new( + new HttpClient(), new WebUntisSettingsService(BuildTempPath()), + new FakeUntisSnapshots(), mappings, new FakeSubstitutionEntries(), new FakeGroups([]), + new FakeTimetableSlots(), new FakeSupervisionDuties(), new FakeSchoolHolidays(), + new PublicHolidayService(), new SchoolCalendarSettingsService(BuildTempPath()), + new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService()); + + private static UntisMappingReviewDialogViewModel BuildDialogVm(FakeUntisSlotMappings mappings, + List? groups = null) => + new(BuildUntisSyncService(mappings), mappings, groups ?? [], new FakeSubjects([])); + + private static List Options(params LearningGroup[] groups) => + groups.Select(g => new UntisGroupOption(g, null)).ToList(); + + private static UntisSlotMatch BuildLessonMatch(Guid? groupId, string classToken = "10c") => new() + { + Pattern = new UntisWeeklyPattern + { + Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), EndTime = new TimeOnly(9, 20), + Summary = "SOL", ClassTokens = [classToken], OccurrenceCount = 40, + }, + PeriodNumber = 1, SuggestedGroupId = groupId, IsConfident = groupId is not null, + }; + + private static UntisSlotMatch BuildSupervisionMatch() => new() + { + Pattern = new UntisWeeklyPattern + { + Weekday = DayOfWeek.Tuesday, StartTime = new TimeOnly(9, 20), EndTime = new TimeOnly(9, 40), + Summary = null, ClassTokens = [], OccurrenceCount = 40, + }, + AfterPeriod = 2, + }; + + [Fact] + public void UntisMappingRow_VorschlagWirdVorausgewaehlt() + { + var group = new LearningGroup { Name = "10c" }; + var row = new UntisMappingRow(BuildLessonMatch(group.Id), Options(group), existing: null); + + Assert.Equal(group.Id, row.SelectedGroup?.Group.Id); + Assert.True(row.IsConfident); + Assert.False(row.IsSupervisionCandidate); + } + + [Fact] + public void UntisMappingRow_OhneVorschlag_BleibtLeer() + { + var row = new UntisMappingRow(BuildLessonMatch(null), [], existing: null); + + Assert.Null(row.SelectedGroup); + Assert.False(row.IsConfident); + } + + [Fact] + public void UntisGroupOption_GleicherNameVerschiedeneFaecher_ZeigtFachImDisplayLabel() + { + // Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das Fach dabei, kann ich nicht + // sicher die richtige Lerngruppe hier auswählen." + var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, "Chemie"); + + Assert.Equal("10c (Chemie)", option.DisplayLabel); + } + + [Fact] + public void UntisGroupOption_OhneFach_ZeigtNurDenNamen() + { + var option = new UntisGroupOption(new LearningGroup { Name = "10c" }, null); + + Assert.Equal("10c", option.DisplayLabel); + } + + [Fact] + public void UntisMappingRow_AufsichtsMuster_WirdAlsSupervisionCandidateErkanntUndKannAufgeloestWerden() + { + // Nutzer-Feedback: "Zwei Termine sind meine Aufsichten [...] vom Zeitraster und von der + // Dauer her könnten die erfasst werden" - kein Klassenbezug, aber AfterPeriod auflösbar. + var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null); + + Assert.True(row.IsSupervisionCandidate); + Assert.True(row.CanResolve); + Assert.False(row.ConfirmAsSupervision); + } + + [Fact] + public void Save_SchreibtNurZeilenMitAusgewaehlterGruppeAlsBestaetigt() + { + var groupA = new LearningGroup { Name = "10c" }; + var groupB = new LearningGroup { Name = "10d" }; + var mappings = new FakeUntisSlotMappings(); + var vm = BuildDialogVm(mappings, [groupA, groupB]); + vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null)); + vm.Rows.Add(new UntisMappingRow(BuildLessonMatch(null, "9z"), Options(groupA, groupB), existing: null)); // ignoriert + + vm.SaveCommand.Execute(null); + + var saved = Assert.Single(mappings.GetAll()); + Assert.True(saved.Confirmed); + Assert.Equal(SubstitutionKind.Lesson, saved.Kind); + Assert.Equal(groupA.Id, saved.GroupId); + Assert.Equal(1, saved.PeriodNumber); + } + + [Fact] + public void Save_AufsichtBestaetigt_SchreibtSupervisionMapping() + { + var mappings = new FakeUntisSlotMappings(); + var vm = BuildDialogVm(mappings); + var row = new UntisMappingRow(BuildSupervisionMatch(), [], existing: null) { ConfirmAsSupervision = true }; + vm.Rows.Add(row); + + vm.SaveCommand.Execute(null); + + var saved = Assert.Single(mappings.GetAll()); + Assert.Equal(SubstitutionKind.Supervision, saved.Kind); + Assert.Null(saved.GroupId); + Assert.Equal(2, saved.AfterPeriod); + Assert.True(saved.Confirmed); + } + + [Fact] + public void Save_ErneutesSpeichernDerselbenZeile_AktualisiertStattDupliziert() + { + // Regression für "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" - + // wiederholtes Bestätigen desselben Slots darf keine zweite Zeile anlegen (sonst + // ArgumentException in UntisDiffService.Diff beim nächsten Poll, siehe dortigen Test). + var groupA = new LearningGroup { Name = "10c" }; + var groupB = new LearningGroup { Name = "10d" }; + var mappings = new FakeUntisSlotMappings(); + var vm1 = BuildDialogVm(mappings, [groupA, groupB]); + vm1.Rows.Add(new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null)); + vm1.SaveCommand.Execute(null); + + var firstSaved = Assert.Single(mappings.GetAll()); + + // Dialog erneut geöffnet - die bereits bestätigte Gruppe muss vorbefüllt sein. + var existing = mappings.GetAll().Single(); + var row2 = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing); + Assert.Equal(groupA.Id, row2.SelectedGroup?.Group.Id); + + row2.SelectedGroup = Options(groupB).Single(); // Nutzer korrigiert + var vm2 = BuildDialogVm(mappings, [groupA, groupB]); + vm2.Rows.Add(row2); + vm2.SaveCommand.Execute(null); + + var saved = Assert.Single(mappings.GetAll()); + Assert.Equal(firstSaved.Id, saved.Id); + Assert.Equal(groupB.Id, saved.GroupId); + } + + [Fact] + public void Save_NutzerAendertVorschlag_UebernimmtNeueAuswahl() + { + var groupA = new LearningGroup { Name = "10c" }; + var groupB = new LearningGroup { Name = "10d" }; + var mappings = new FakeUntisSlotMappings(); + var vm = BuildDialogVm(mappings, [groupA, groupB]); + var row = new UntisMappingRow(BuildLessonMatch(groupA.Id), Options(groupA, groupB), existing: null); + row.SelectedGroup = Options(groupB).Single(); + vm.Rows.Add(row); + + vm.SaveCommand.Execute(null); + + Assert.Equal(groupB.Id, Assert.Single(mappings.GetAll()).GroupId); + } +} diff --git a/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs b/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs new file mode 100644 index 0000000..6e0ae3c --- /dev/null +++ b/LehrerApp.Desktop.Tests/UntisSyncServiceTests.cs @@ -0,0 +1,285 @@ +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +/// Tests für den HTTP-freien Verarbeitungskern von UntisSyncService — kein echter Abruf nötig, +/// synthetischer ICS-Text nach dem realen WebUntis-Format (siehe Planungsdokument). +public sealed class UntisSyncServiceTests +{ + private static string BuildIcs(string uid, string dtstart, string? summary, string description, string status = "CONFIRMED") => + "BEGIN:VCALENDAR\nVERSION:2.0\n" + + "BEGIN:VEVENT\n" + + $"UID:{uid}\n" + + $"STATUS:{status}\n" + + $"DTSTART;TZID=Europe/Berlin:{dtstart}\n" + + $"DTEND;TZID=Europe/Berlin:{dtstart}\n" + + (summary is null ? "" : $"SUMMARY:{summary}\n") + + $"DESCRIPTION:{description}\n" + + "END:VEVENT\nEND:VCALENDAR\n"; + + private static string EmptyIcs() => "BEGIN:VCALENDAR\nVERSION:2.0\nEND:VCALENDAR\n"; + + private static UntisSyncService BuildService(FakeUntisSnapshots? snapshots = null, + FakeUntisSlotMappings? mappings = null, FakeSubstitutionEntries? substitutions = null, + FakeGroups? groups = null, FakeTimetableSlots? timetableSlots = null, + FakeSupervisionDuties? supervisionDuties = null, FakeSchoolHolidays? schoolHolidays = null) => new( + new HttpClient(), new WebUntisSettingsService(BuildTempPath()), + snapshots ?? new FakeUntisSnapshots(), mappings ?? new FakeUntisSlotMappings(), + substitutions ?? new FakeSubstitutionEntries(), groups ?? new FakeGroups([]), + timetableSlots ?? new FakeTimetableSlots(), supervisionDuties ?? new FakeSupervisionDuties(), + schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), + new SchoolCalendarSettingsService(BuildTempPath()), + new PeriodScheduleService(BuildTempPath()), new UntisMatchingService(), new UntisDiffService()); + + private static string BuildTempPath() + { + var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-untissync-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + [Fact] + public void ProcessIcsText_UnveraendertesEreignis_SchreibtKeineVertretungAberSnapshot() + { + var mappings = new FakeUntisSlotMappings(); + mappings.Add(new UntisSlotMapping + { + Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL", + ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true, + }); + var snapshots = new FakeUntisSnapshots(); + var service = BuildService(snapshots: snapshots, mappings: mappings); + + var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "SOL", "10c HED")); + + Assert.Equal(1, result.EventCount); + Assert.Equal(0, result.SubstitutionCount); + Assert.Single(snapshots.GetAll()); + } + + [Fact] + public void ProcessIcsText_AbweichendesFach_SchreibtVertretungUndAktualisiertBeiWiederholung() + { + var mappings = new FakeUntisSlotMappings(); + mappings.Add(new UntisSlotMapping + { + Weekday = DayOfWeek.Monday, StartTime = new TimeOnly(7, 50), Summary = "SOL", + ClassToken = "10c", GroupId = Guid.NewGuid(), PeriodNumber = 1, Confirmed = true, + }); + var snapshots = new FakeUntisSnapshots(); + var substitutions = new FakeSubstitutionEntries(); + var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions); + + var first = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED")); + Assert.Equal(1, first.SubstitutionCount); + Assert.Single(substitutions.GetAll()); + + // Erneuter Poll mit demselben (noch immer abweichenden) Termin darf keinen zweiten + // Eintrag erzeugen - Idempotenz über SubstitutionEntry.ExternalId. + var second = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED")); + + Assert.Equal(1, second.SubstitutionCount); + Assert.Single(substitutions.GetAll()); + } + + // ── Regression: "Am 27.08. fällt eine Stunde NAT in der 8c aus. Dieser Ausfall steht nicht + // im Plan [...] die Klasse ist dort weg" — end-to-end über ProcessIcsText, inkl. der neuen + // Ferien-Ausschluss-Verdrahtung (ISchoolHolidayRepository -> UntisDiffService.freeDates). + + [Fact] + public void ProcessIcsText_VonAnfangAnFehlendeStunde_WirdAlsAusfallGemeldet() + { + var missingDate = DateTime.Today.AddDays(3); + var mapping = new UntisSlotMapping + { + Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT", + ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true, + }; + var mappings = new FakeUntisSlotMappings(); + mappings.Add(mapping); + var substitutions = new FakeSubstitutionEntries(); + var service = BuildService(mappings: mappings, substitutions: substitutions); + + // Kein Termin für missingDate im Feed (die 8c ist weg) - ein unbeteiligter, etwas + // späterer Termin belegt lediglich den Feed-Abdeckungshorizont darüber hinaus. + var coverageDate = missingDate.AddDays(2); + var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED")); + + Assert.Equal(1, result.SubstitutionCount); + var entry = Assert.Single(substitutions.GetAll()); + Assert.Equal(SubstitutionKind.Cancelled, entry.Kind); + Assert.Equal(3, entry.PeriodNumber); + } + + [Fact] + public void ProcessIcsText_FehlendeStundeAnFerientag_WirdNichtGemeldet() + { + var missingDate = DateTime.Today.AddDays(3); + var mapping = new UntisSlotMapping + { + Weekday = missingDate.DayOfWeek, StartTime = new TimeOnly(9, 40), Summary = "NAT", + ClassToken = "8c", GroupId = Guid.NewGuid(), PeriodNumber = 3, Confirmed = true, + }; + var mappings = new FakeUntisSlotMappings(); + mappings.Add(mapping); + var schoolHolidays = new FakeSchoolHolidays(); + schoolHolidays.Add(new SchoolHoliday + { + StartDate = DateOnly.FromDateTime(missingDate), EndDate = DateOnly.FromDateTime(missingDate), + }); + var substitutions = new FakeSubstitutionEntries(); + var service = BuildService(mappings: mappings, substitutions: substitutions, schoolHolidays: schoolHolidays); + + var coverageDate = missingDate.AddDays(2); + var result = service.ProcessIcsText(BuildIcs("other", coverageDate.ToString("yyyyMMdd") + "T093000", "ANDERES", "9x HED")); + + Assert.Equal(0, result.SubstitutionCount); + Assert.Empty(substitutions.GetAll()); + } + + [Fact] + public void ProcessIcsText_OhneBestaetigteZuordnung_SchreibtNieEineVertretung() + { + var substitutions = new FakeSubstitutionEntries(); + var service = BuildService(substitutions: substitutions); + + var result = service.ProcessIcsText(BuildIcs("1", "20260817T075000", "NAT", "10c HED")); + + Assert.Equal(0, result.SubstitutionCount); + Assert.Empty(substitutions.GetAll()); + } + + [Fact] + public void BuildMatchPreview_LiefertVorschlagFuerBekannteGruppe() + { + var group = new LearningGroup { Name = "10c" }; + var service = BuildService(groups: new FakeGroups([group])); + + var preview = service.BuildMatchPreview(BuildIcs("1", "20260817T075000", "SOL", "10c HED")); + + Assert.Equal(1, preview.EventCount); + var match = Assert.Single(preview.Matches.Matches); + Assert.Equal(group.Id, match.SuggestedGroupId); + } + + // ── Nutzer-Feedback: "Ich habe eine Aufsicht vertretungsweise bekommen [...] Die fehlt + // natürlich in der Woche drauf wieder. Wird dann bis zum Ende des gültigen Stundenplans diese + // Stunde als entfallene Aufsicht geführt?" — die "entfallen"-Meldung ist an die konkrete, + // tatsächlich gesehene Snapshot-Zeile eines Datums gekoppelt, nicht an eine dauerhaft + // erwartete wöchentliche Wiederholung: sobald sie einmal als entfallen gemeldet und aus dem + // Snapshot entfernt wurde, gibt es nichts mehr, das in einer Folgewoche erneut "verschwinden" + // könnte, solange WebUntis für diesen Slot keinen neuen Termin mehr listet. Mit einer + // passenden regulären SupervisionDuty, damit dieser Test ausschließlich den + // Verschwinden-Mechanismus prüft, unabhängig von der "Zusätzliche Aufsicht"-Erkennung unten. + [Fact] + public void ProcessIcsText_RegulaereAufsichtVerschwindetEinmalig_MeldetEntfallenNurEinmal() + { + var eventDate = DateTime.Today.AddDays(3); // innerhalb des 14-Tage-Lookaheads + var dtstart = eventDate.ToString("yyyyMMdd") + "T093000"; + var mapping = new UntisSlotMapping + { + Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30), + Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true, + }; + var mappings = new FakeUntisSlotMappings(); + mappings.Add(mapping); + var duties = new FakeSupervisionDuties(); + duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" }); + var snapshots = new FakeUntisSnapshots(); + var substitutions = new FakeSubstitutionEntries(); + var service = BuildService(snapshots: snapshots, mappings: mappings, substitutions: substitutions, supervisionDuties: duties); + + // Poll 1: Feed enthält die reguläre Aufsicht wie erwartet - keine Meldung nötig. + var first = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED")); + Assert.Equal(0, first.SubstitutionCount); + Assert.Single(snapshots.GetAll()); + + // Poll 2: WebUntis listet an diesem Slot keinen Termin mehr (einmalig von jemand anderem + // übernommen). Genau EINE "entfallen"-Meldung, Snapshot-Zeile wird bereinigt. + var second = service.ProcessIcsText(EmptyIcs()); + Assert.Equal(1, second.SubstitutionCount); + var entry = Assert.Single(substitutions.GetAll()); + Assert.Equal(SubstitutionKind.Supervision, entry.Kind); + Assert.Empty(snapshots.GetAll()); + + // Poll 3, 4, ...: weiterhin kein Termin an diesem Slot - keine erneute Meldung, "bis zum + // Ende des gültigen Stundenplans" bleibt es bei dem einen Eintrag. + var third = service.ProcessIcsText(EmptyIcs()); + Assert.Equal(0, third.SubstitutionCount); + Assert.Single(substitutions.GetAll()); + } + + // ── Nutzer-Feedback: "Auch die Extra-Aufsicht ist dann nicht im Plan [...] So macht doch der + // Sync nur so halb Sinn" — eine bestätigte Aufsichts-Zuordnung OHNE passende reguläre + // SupervisionDuty (Einstellungen "Aufsichten") ist selbst schon die meldenswerte Vertretung + // und soll deshalb sofort bei jedem Vorkommen als SubstitutionEntry sichtbar werden, nicht + // erst bei einer Abweichung davon. + + [Fact] + public void ProcessIcsText_ZusaetzlicheAufsichtOhneRegulaereDuty_WirdSofortGemeldet() + { + var eventDate = DateTime.Today.AddDays(3); + var dtstart = eventDate.ToString("yyyyMMdd") + "T093000"; + var mapping = new UntisSlotMapping + { + Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30), + Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true, + }; + var mappings = new FakeUntisSlotMappings(); + mappings.Add(mapping); + var substitutions = new FakeSubstitutionEntries(); + var service = BuildService(mappings: mappings, substitutions: substitutions); + + var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED")); + + Assert.Equal(1, result.SubstitutionCount); + var entry = Assert.Single(substitutions.GetAll()); + Assert.Equal(SubstitutionKind.Supervision, entry.Kind); + Assert.Equal(1, entry.AfterPeriod); + Assert.Contains("Zusätzliche Aufsicht", entry.Description); + + // Erneuter Poll mit demselben, weiterhin unverändert vorhandenen Termin darf keinen + // zweiten Eintrag erzeugen (Idempotenz über ExternalId=Uid). + var second = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED")); + Assert.Equal(1, second.SubstitutionCount); + Assert.Single(substitutions.GetAll()); + } + + [Fact] + public void ProcessIcsText_AufsichtMitRegulaererDuty_MeldetNichtsBeiNormalerAnwesenheit() + { + var eventDate = DateTime.Today.AddDays(3); + var dtstart = eventDate.ToString("yyyyMMdd") + "T093000"; + var mapping = new UntisSlotMapping + { + Weekday = eventDate.DayOfWeek, StartTime = new TimeOnly(9, 30), + Kind = SubstitutionKind.Supervision, AfterPeriod = 1, Confirmed = true, + }; + var mappings = new FakeUntisSlotMappings(); + mappings.Add(mapping); + var duties = new FakeSupervisionDuties(); + duties.Add(new SupervisionDuty { Weekday = eventDate.DayOfWeek, AfterPeriod = 1, Location = "Pausenhof" }); + var substitutions = new FakeSubstitutionEntries(); + var service = BuildService(mappings: mappings, substitutions: substitutions, supervisionDuties: duties); + + var result = service.ProcessIcsText(BuildIcs("v1", dtstart, null, "HED")); + + Assert.Equal(0, result.SubstitutionCount); + Assert.Empty(substitutions.GetAll()); + } + + [Fact] + public void ConfirmMappings_SpeichertAlsBestaetigt() + { + var mappings = new FakeUntisSlotMappings(); + var service = BuildService(mappings: mappings); + var mapping = new UntisSlotMapping { Weekday = DayOfWeek.Monday, GroupId = Guid.NewGuid() }; + + service.ConfirmMappings([mapping]); + + Assert.True(Assert.Single(mappings.GetAll()).Confirmed); + } +} diff --git a/LehrerApp.Desktop.Tests/WebUntisSettingsServiceTests.cs b/LehrerApp.Desktop.Tests/WebUntisSettingsServiceTests.cs new file mode 100644 index 0000000..03ce7a4 --- /dev/null +++ b/LehrerApp.Desktop.Tests/WebUntisSettingsServiceTests.cs @@ -0,0 +1,89 @@ +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class WebUntisSettingsServiceTests +{ + private const string SampleUrl = "https://example.webuntis.com/WebUntis/ical_export?school=example&id=test&elemId=1&token=geheim"; + + private static string BuildTempPath() + { + var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-webuntissettingssvc-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + [Fact] + public void SetEnabled_PersistiertUeberNeueInstanz() + { + var path = BuildTempPath(); + new WebUntisSettingsService(path).SetEnabled(true); + + var reloaded = new WebUntisSettingsService(path); + + Assert.True(reloaded.Enabled); + } + + [Fact] + public void SetIcalUrl_IstVerschluesseltAbrufbar() + { + var service = new WebUntisSettingsService(BuildTempPath()); + + service.SetIcalUrl(SampleUrl); + + Assert.True(service.IsConfigured); + Assert.Equal(SampleUrl, service.GetIcalUrl()); + } + + [Fact] + public void IcalUrl_UeberlebtNeueInstanzMitDemselbenPfad() + { + var path = BuildTempPath(); + new WebUntisSettingsService(path).SetIcalUrl(SampleUrl); + + var reloaded = new WebUntisSettingsService(path); + + Assert.True(reloaded.IsConfigured); + Assert.Equal(SampleUrl, reloaded.GetIcalUrl()); + } + + [Fact] + public void KonfigurationsdateiEnthaeltNichtDasTokenImKlartext() + { + var path = BuildTempPath(); + var service = new WebUntisSettingsService(path); + service.SetIcalUrl(SampleUrl); + + var raw = File.ReadAllText(Path.Combine(path, "webuntis-settings.json")); + + Assert.DoesNotContain("token=geheim", raw); + } + + [Fact] + public void ClearIcalUrl_EntferntUrlUndDeaktiviert() + { + var service = new WebUntisSettingsService(BuildTempPath()); + service.SetIcalUrl(SampleUrl); + service.SetEnabled(true); + + service.ClearIcalUrl(); + + Assert.False(service.IsConfigured); + Assert.Null(service.GetIcalUrl()); + Assert.False(service.Enabled); + } + + [Fact] + public void SetLastSync_PersistiertZeitstempelUndStatus() + { + var path = BuildTempPath(); + var at = new DateTime(2026, 8, 22, 12, 0, 0, DateTimeKind.Utc); + new WebUntisSettingsService(path).SetLastSync(at, "3 Vertretungen erkannt"); + + var reloaded = new WebUntisSettingsService(path); + + Assert.Equal(at, reloaded.LastSyncAt); + Assert.Equal("3 Vertretungen erkannt", reloaded.LastSyncStatus); + } +} diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index 0eb89af..bfdd8a5 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -165,6 +165,8 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); // ── Services ────────────────────────────────────────────────────────── services.AddSingleton(); @@ -186,6 +188,24 @@ public static class AppBootstrapper services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) }); services.AddSingleton(); + // ── WebUntis-iCal-Abgleich (optional – nur wenn URL hinterlegt und aktiviert) ───────── + var untisSettings = new WebUntisSettingsService(appData); + services.AddSingleton(untisSettings); + services.AddSingleton(); + services.AddSingleton(); + if (untisSettings.Enabled && !string.IsNullOrEmpty(untisSettings.GetIcalUrl())) + { + services.AddSingleton(sp => new UntisSyncService( + new HttpClient(), untisSettings, + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService(), sp.GetRequiredService(), + sp.GetRequiredService())); + } + // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── var syncSettings = new SyncSettingsService(appData); services.AddSingleton(syncSettings); diff --git a/LehrerApp.Desktop/Services/UntisSyncService.cs b/LehrerApp.Desktop/Services/UntisSyncService.cs new file mode 100644 index 0000000..e371011 --- /dev/null +++ b/LehrerApp.Desktop/Services/UntisSyncService.cs @@ -0,0 +1,187 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.Services; + +/// Ergebnis eines einzelnen Verarbeitungsdurchlaufs (Abruf oder Test-Text) — für +/// Statusanzeige/Logging. +public sealed record UntisPollResult(int EventCount, int SubstitutionCount); + +/// Für den Zuordnungs-Review-Dialog: das Rohergebnis der Musteranalyse (Stufe 1) für +/// einen konkreten Abruf, ohne dass dabei schon etwas gespeichert wird. +public sealed record UntisMatchPreview(int EventCount, UntisMatchResult Matches); + +/// +/// Orchestriert den periodischen WebUntis-iCal-Abgleich (siehe TODO.md/Planungsdokument): Abruf +/// per HTTP, Parsen (IcsParser), Musterabgleich (UntisMatchingService) sowie laufender +/// Schnappschuss-Abgleich (UntisDiffService), dessen Ergebnis über die Repositories geschrieben +/// wird. Gleiches Timer/Gate/Dispose-Muster wie LehrerApp.Sync.SyncEngine. +/// +/// Der reine Verarbeitungskern () ist bewusst ohne HTTP-Zugriff +/// gehalten (public statt internal, da diese Codebasis kein InternalsVisibleTo nutzt), damit er +/// direkt mit vorgefertigtem ICS-Text getestet werden kann, ohne einen echten Abruf zu brauchen. +/// +public class UntisSyncService : IDisposable +{ + private const int PollIntervalMinutes = 60; + + private readonly HttpClient _http; + private readonly WebUntisSettingsService _settings; + private readonly IUntisSnapshotRepository _snapshots; + private readonly IUntisSlotMappingRepository _mappings; + private readonly ISubstitutionEntryRepository _substitutions; + private readonly IGroupRepository _groups; + private readonly ITimetableSlotRepository _timetableSlots; + private readonly ISupervisionDutyRepository _supervisionDuties; + private readonly ISchoolHolidayRepository _schoolHolidays; + private readonly PublicHolidayService _publicHolidays; + private readonly SchoolCalendarSettingsService _calendarSettings; + private readonly PeriodScheduleService _periodSchedule; + private readonly UntisMatchingService _matchingService; + private readonly UntisDiffService _diffService; + private readonly AppLogger? _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Timer _timer; + + /// Feuert, wenn ein Poll tatsächlich Vertretungen geschrieben hat — Timetable/Dashboard können + /// dann bei Bedarf neu laden (EventApplier-Muster: direktes Schreiben an ViewModels vorbei). + public event Action? DataChanged; + + public UntisSyncService(HttpClient http, WebUntisSettingsService settings, IUntisSnapshotRepository snapshots, + IUntisSlotMappingRepository mappings, ISubstitutionEntryRepository substitutions, IGroupRepository groups, + ITimetableSlotRepository timetableSlots, ISupervisionDutyRepository supervisionDuties, + ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, + SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, + UntisMatchingService matchingService, UntisDiffService diffService, AppLogger? logger = null) + { + _http = http; _settings = settings; _snapshots = snapshots; _mappings = mappings; + _substitutions = substitutions; _groups = groups; _timetableSlots = timetableSlots; + _supervisionDuties = supervisionDuties; + _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; + _periodSchedule = periodSchedule; _matchingService = matchingService; _diffService = diffService; + _logger = logger; + _timer = new Timer(async _ => await PollAsync(), null, + TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes)); + } + + public async Task PollAsync() + { + if (!await _gate.WaitAsync(0)) return; + try + { + var url = _settings.GetIcalUrl(); + if (string.IsNullOrEmpty(url)) return; + + string icsText; + try + { + icsText = await _http.GetStringAsync(url); + } + catch (Exception ex) + { + _logger?.Error("WebUntis-Abgleich: Abruf fehlgeschlagen", ex); + _settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}"); + return; + } + + UntisPollResult result; + try + { + result = ProcessIcsText(icsText); + } + catch (Exception ex) + { + // Darf NICHT aus PollAsync herausfallen: der Timer-Callback fängt keine + // Ausnahmen ab, eine unbehandelte Exception hier würde den gesamten Prozess + // beenden (gleiche Begründung wie EventApplier in LehrerApp.Sync). + _logger?.Error("WebUntis-Abgleich: Verarbeitung fehlgeschlagen", ex); + _settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}"); + return; + } + _settings.SetLastSync(DateTime.UtcNow, + $"{result.SubstitutionCount} Änderung(en) erkannt ({result.EventCount} Termine geprüft)."); + _logger?.Info($"WebUntis-Abgleich: {result.SubstitutionCount} Änderung(en) aus {result.EventCount} Terminen."); + if (result.SubstitutionCount > 0) DataChanged?.Invoke(); + } + finally { _gate.Release(); } + } + + // Reiner Kern ohne HTTP - direkt mit vorgefertigtem ICS-Text testbar (kein InternalsVisibleTo + // in dieser Codebasis üblich, siehe CLAUDE.md - deshalb public statt internal). + public UntisPollResult ProcessIcsText(string icsText) + { + var events = IcsParser.Parse(icsText); + var previousSnapshot = _snapshots.GetAll(); + var confirmedMappings = _mappings.GetAll(); + var today = DateOnly.FromDateTime(DateTime.Today); + var diffResult = _diffService.Diff(events, previousSnapshot, confirmedMappings, today, + existingSupervisionDuties: _supervisionDuties.GetAll(), freeDates: BuildFreeDates(today)); + + foreach (var candidate in diffResult.SubstitutionsToSave) + { + var existing = candidate.ExternalId is not null ? _substitutions.GetByExternalId(candidate.ExternalId) : null; + if (existing is not null) candidate.Id = existing.Id; + _substitutions.Save(candidate); + } + foreach (var snapshot in diffResult.SnapshotToSave) _snapshots.Save(snapshot); + foreach (var id in diffResult.SnapshotIdsToDelete) _snapshots.Delete(id); + + return new UntisPollResult(events.Count, diffResult.SubstitutionsToSave.Count); + } + + // Ferien-/Feiertagstage im relevanten Zeitfenster (deutlich über das Lookahead-Fenster + // hinaus, kostet bei kleinen Ferienlisten nichts) - verhindert, dass die aktive + // "fehlt komplett im Feed"-Prüfung in UntisDiffService Ferientage fälschlich als Ausfall + // meldet, an denen WebUntis ohnehin keine Termine führt. Gleiche Logik wie + // TimetableViewModel.IsFreeDay, hier separat gehalten statt geteilt, da UntisDiffService + // (LehrerApp.Core) bewusst framework-frei bleibt und keine Desktop-ViewModels referenziert. + private HashSet BuildFreeDates(DateOnly today) + { + var horizonEnd = today.AddDays(90); + var freeDates = new HashSet(); + foreach (var year in new[] { today.Year, today.Year + 1 }) + foreach (var h in _publicHolidays.GetHolidays(year, _calendarSettings.State)) + freeDates.Add(h.Date); + + var schoolHolidays = _schoolHolidays.GetAll(); + for (var date = today; date <= horizonEnd; date = date.AddDays(1)) + if (schoolHolidays.Any(h => date >= h.StartDate && date <= h.EndDate)) + freeDates.Add(date); + + return freeDates; + } + + /// Für den Zuordnungs-Review-Dialog (Stufe 1, ohne etwas zu speichern). + public UntisMatchPreview BuildMatchPreview(string icsText) + { + var events = IcsParser.Parse(icsText); + var groups = _groups.GetAll(); + var timetableSlots = _timetableSlots.GetAll(); + var matches = _matchingService.BuildMatches(events, groups, timetableSlots, _periodSchedule); + return new UntisMatchPreview(events.Count, matches); + } + + public async Task FetchAndBuildMatchPreviewAsync() + { + var url = _settings.GetIcalUrl(); + if (string.IsNullOrEmpty(url)) return null; + var icsText = await _http.GetStringAsync(url); + return BuildMatchPreview(icsText); + } + + public void ConfirmMappings(IEnumerable confirmed) + { + foreach (var mapping in confirmed) + { + mapping.Confirmed = true; + _mappings.Save(mapping); + } + } + + public void Dispose() + { + _timer.Dispose(); + _gate.Dispose(); + } +} diff --git a/LehrerApp.Desktop/Services/WebUntisSettingsService.cs b/LehrerApp.Desktop/Services/WebUntisSettingsService.cs new file mode 100644 index 0000000..65f7350 --- /dev/null +++ b/LehrerApp.Desktop/Services/WebUntisSettingsService.cs @@ -0,0 +1,93 @@ +using LehrerApp.Sync.Crypto; +using System.Text.Json; + +namespace LehrerApp.Desktop.Services; + +internal class WebUntisSettingsConfig +{ + public bool Enabled { get; set; } + public string? EncryptedIcalUrl { get; set; } + public DateTime? LastSyncAt { get; set; } + public string LastSyncStatus { get; set; } = ""; +} + +/// +/// Einstellungen für den WebUntis-iCal-Abgleich (Nutzer-Feedback, siehe TODO.md). Liegt wie +/// AiSettingsService/SyncSettingsService in LehrerApp.Desktop statt LehrerApp.Core, da die +/// Verschlüsselung über aus LehrerApp.Sync läuft — Core bleibt bewusst +/// frei von Abhängigkeiten außerhalb von .NET selbst (siehe CLAUDE.md). +/// +/// Die iCal-URL trägt ein eingebettetes Auth-Token und wird deshalb wie ein Passwort behandelt: +/// nie im Klartext persistiert, nur AES-256-GCM-verschlüsselt (gleicher Mechanismus wie beim +/// KI-Backend-Token) mit einem eigenen, dateirechte-geschützten Schlüssel. +/// +public class WebUntisSettingsService +{ + private readonly string _configPath; + private readonly string _keyPath; + private readonly byte[] _urlKey; + private WebUntisSettingsConfig _config; + + public bool Enabled => _config.Enabled; + public bool IsConfigured => _config.EncryptedIcalUrl is not null; + public DateTime? LastSyncAt => _config.LastSyncAt; + public string LastSyncStatus => _config.LastSyncStatus; + + public WebUntisSettingsService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "webuntis-settings.json"); + _keyPath = Path.Combine(appDataPath, "webuntis-url.key"); + _urlKey = SyncCrypto.LoadKey(_keyPath) ?? GenerateAndSaveKey(); + _config = Load(); + } + + public void SetEnabled(bool enabled) + { + _config.Enabled = enabled; + Save(); + } + + public void SetIcalUrl(string url) + { + _config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey); + Save(); + } + + public string? GetIcalUrl() => + _config.EncryptedIcalUrl is null ? null : SyncCrypto.DecryptObject(_config.EncryptedIcalUrl, _urlKey); + + public void ClearIcalUrl() + { + _config.EncryptedIcalUrl = null; + _config.Enabled = false; + Save(); + } + + public void SetLastSync(DateTime at, string status) + { + _config.LastSyncAt = at; + _config.LastSyncStatus = status; + Save(); + } + + private byte[] GenerateAndSaveKey() + { + var key = SyncCrypto.GenerateKey(); + SyncCrypto.SaveKey(key, _keyPath); + return key; + } + + private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); + + private WebUntisSettingsConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new WebUntisSettingsConfig(); + } + catch { /* beschädigte Konfiguration -> Standardwert */ } + return new WebUntisSettingsConfig(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs index fe3bf26..faf2441 100644 --- a/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Planning/TimetableViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; using LehrerApp.Desktop.ViewModels.Settings; using System.Collections.ObjectModel; @@ -62,6 +63,8 @@ public partial class TimetableViewModel : ObservableObject private readonly SchoolYearService _schoolYear; private readonly ISupervisionDutyRepository _supervisionDuties; private readonly ISubstitutionEntryRepository _substitutions; + private readonly IUntisSlotMappingRepository _untisMappings; + private readonly WebUntisSettingsService _untisSettings; public ObservableCollection Cells { get; } = []; public ObservableCollection WeekItems { get; } = []; @@ -86,6 +89,13 @@ public partial class TimetableViewModel : ObservableObject [ObservableProperty] private int _weekOffset; public bool IsCurrentWeek => WeekOffset == 0; + // ── WebUntis-Abweichung (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt") ────── + // Kein erneuter iCal-Abruf hier - vergleicht nur den lokal bereits bestätigten + // Zuordnungsstand (UntisSlotMapping, siehe UntisMappingReviewDialog) gegen die aktuellen + // TimetableSlots. Bleibt komplett verborgen, solange der Abgleich nicht aktiviert ist. + [ObservableProperty] private bool _hasUntisMismatch; + [ObservableProperty] private string _untisMismatchLabel = ""; + public Func? OnEditSlot { get; set; } public Action? OnNavigateToGroup { get; set; } public Func? OnAddSubstitution { get; set; } @@ -97,12 +107,14 @@ public partial class TimetableViewModel : ObservableObject ISubjectRepository subjects, ILessonRepository lessons, IExamRepository exams, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, PublicHolidayService publicHolidays, SchoolYearService schoolYear, - ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions) + ISupervisionDutyRepository supervisionDuties, ISubstitutionEntryRepository substitutions, + IUntisSlotMappingRepository untisMappings, WebUntisSettingsService untisSettings) { _slots = slots; _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _schoolHolidays = schoolHolidays; _calendarSettings = calendarSettings; _publicHolidays = publicHolidays; _schoolYear = schoolYear; _supervisionDuties = supervisionDuties; _substitutions = substitutions; + _untisMappings = untisMappings; _untisSettings = untisSettings; Load(); } @@ -143,8 +155,33 @@ public partial class TimetableViewModel : ObservableObject BuildToday(today); BuildHoursWarnings(); BuildUpcomingExams(today); + LoadUntisMismatch(); } + private void LoadUntisMismatch() + { + if (!_untisSettings.Enabled || !_untisSettings.IsConfigured) { HasUntisMismatch = false; return; } + + // CoveredPeriods statt nur PeriodNumber: bei einer von WebUntis zu einem Termin + // zusammengefassten Doppelstunde bestätigt eine einzige Zuordnung mehrere TimetableSlots + // auf einmal (siehe UntisSlotMapping.CoveredPeriods-Dokumentation). + var confirmedKeys = _untisMappings.GetAll() + .Where(m => m.Confirmed && m.Kind == SubstitutionKind.Lesson && m.GroupId is not null) + .SelectMany(m => (m.CoveredPeriods.Count > 0 ? m.CoveredPeriods : m.PeriodNumber is { } p ? [p] : []) + .Select(period => (m.Weekday, period, GroupId: m.GroupId!.Value))) + .ToHashSet(); + var mismatchCount = _slots.GetAll() + .Count(s => !confirmedKeys.Contains((s.Weekday, s.PeriodNumber, s.GroupId))); + + HasUntisMismatch = mismatchCount > 0; + UntisMismatchLabel = mismatchCount == 1 + ? "1 Stundenplan-Eintrag ohne bestätigte WebUntis-Zuordnung." + : $"{mismatchCount} Stundenplan-Einträge ohne bestätigte WebUntis-Zuordnung."; + } + + [RelayCommand] + private void ReviewUntisMismatch() => OnNavigateToSettings?.Invoke(SettingsTab.WebUntis); + // ── Anstehende Klausurtermine (4.4.3) ──────────────────────────────────── /// diff --git a/LehrerApp.Desktop/ViewModels/Planning/UntisMappingReviewDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Planning/UntisMappingReviewDialogViewModel.cs new file mode 100644 index 0000000..8030ca0 --- /dev/null +++ b/LehrerApp.Desktop/ViewModels/Planning/UntisMappingReviewDialogViewModel.cs @@ -0,0 +1,188 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; +using System.Collections.ObjectModel; + +namespace LehrerApp.Desktop.ViewModels.Planning; + +/// +/// Zeigt die erkannten regulären WebUntis-Wochenmuster und lässt den Nutzer die vorgeschlagene +/// Lerngruppe je Muster bestätigen oder ändern, bevor der laufende Abgleich (UntisSyncService) +/// automatisch Vertretungen dafür schreibt (Nutzer-Feedback: die erstmalige Zuordnung ist +/// fehleranfällig — falsche Gruppe würde falsche Vertretungen erzeugen — und braucht deshalb eine +/// Bestätigung). Termine ohne Klassenbezug (Aufsichten/Springstunden, Nutzer-Feedback: "Zwei +/// Termine sind meine Aufsichten, die nicht zugeordnet werden können") lassen sich stattdessen per +/// Checkbox als Aufsicht bestätigen. Zusätzlich informativ: Stundenplan-Einträge, zu denen kein +/// WebUntis-Muster mehr passt ("Stundenplan passt nicht mehr"). +/// +/// Beim erneuten Öffnen werden bereits bestätigte Zuordnungen aus dem Repository vorbefüllt +/// (Nutzer-Feedback: "Kann es sein, dass er meine Verbesserungen gar nicht einspeichert" — die +/// Auswahl wurde zwar gespeichert, beim nächsten Öffnen aber von der frischen Musterkennung +/// überschrieben, was wie ein Datenverlust wirkte) und beim Speichern per vorhandener Id +/// aktualisiert statt dupliziert (siehe UntisMappingRow.BuildMapping). +/// +public partial class UntisMappingReviewDialogViewModel : ObservableObject +{ + private readonly UntisSyncService _untisSync; + private readonly IUntisSlotMappingRepository _mappings; + + /// Gruppenname + Fach zur Auswahl (Nutzer-Feedback: "Meine Klasse habe ich 3-mal. Ohne das + /// Fach dabei, kann ich nicht sicher die richtige Lerngruppe hier auswählen") — dieselbe + /// Namenskonflikt-Begründung wie in TimetableSlotDialogViewModel, hier über eine kleine + /// Anzeige-Hülle statt eines Label-Strings, da dieser Dialog direkt an LearningGroup-Objekte + /// bindet. + public List Groups { get; } + public ObservableCollection Rows { get; } = []; + public ObservableCollection UnmatchedSlotLabels { get; } = []; + public bool HasUnmatchedSlots => UnmatchedSlotLabels.Count > 0; + public bool HasNoRows => !IsLoading && Rows.Count == 0; + + [ObservableProperty] private bool _isLoading = true; + [ObservableProperty] private string _errorMessage = ""; + + public bool Result { get; private set; } + + public UntisMappingReviewDialogViewModel(UntisSyncService untisSync, IUntisSlotMappingRepository mappings, + List groups, ISubjectRepository subjects) + { + _untisSync = untisSync; + _mappings = mappings; + var subjectNames = subjects.GetAll().ToDictionary(s => s.Id, s => s.Name); + Groups = groups.OrderBy(g => g.Name) + .Select(g => new UntisGroupOption(g, g.SubjectId is { } sid ? subjectNames.GetValueOrDefault(sid) : null)) + .ToList(); + } + + public async Task LoadAsync() + { + IsLoading = true; + ErrorMessage = ""; + try + { + var preview = await _untisSync.FetchAndBuildMatchPreviewAsync(); + Rows.Clear(); + UnmatchedSlotLabels.Clear(); + if (preview is null) { ErrorMessage = "Keine iCal-URL hinterlegt."; return; } + + // Bei mehreren Mappings für denselben Slot (z.B. Altdaten vor dem Bugfix) gewinnt das + // zuletzt angelegte - gleiche Regel wie UntisDiffService. + var existingByKey = _mappings.GetAll() + .GroupBy(m => (m.Weekday, m.StartTime)) + .ToDictionary(g => g.Key, g => g.OrderByDescending(m => m.CreatedAt).First()); + + foreach (var match in preview.Matches.Matches.OrderBy(m => m.Pattern.Weekday).ThenBy(m => m.Pattern.StartTime)) + { + existingByKey.TryGetValue((match.Pattern.Weekday, match.Pattern.StartTime), out var existing); + Rows.Add(new UntisMappingRow(match, Groups, existing)); + } + foreach (var slot in preview.Matches.UnmatchedTimetableSlots) + UnmatchedSlotLabels.Add($"{WeekdayLabel(slot.Weekday)}, {slot.PeriodNumber}. Stunde"); + OnPropertyChanged(nameof(HasUnmatchedSlots)); + } + catch (Exception ex) { ErrorMessage = $"Abruf fehlgeschlagen: {ex.Message}"; } + finally { IsLoading = false; OnPropertyChanged(nameof(HasNoRows)); } + } + + [RelayCommand] + private void Save() + { + var confirmed = Rows.Select(r => r.BuildMapping()).Where(m => m is not null).Select(m => m!); + _untisSync.ConfirmMappings(confirmed); + Result = true; + } + + private static string WeekdayLabel(DayOfWeek weekday) => weekday switch + { + DayOfWeek.Monday => "Montag", DayOfWeek.Tuesday => "Dienstag", + DayOfWeek.Wednesday => "Mittwoch", DayOfWeek.Thursday => "Donnerstag", + DayOfWeek.Friday => "Freitag", DayOfWeek.Saturday => "Samstag", DayOfWeek.Sunday => "Sonntag", + _ => weekday.ToString(), + }; +} + +public partial class UntisMappingRow : ObservableObject +{ + private readonly Guid? _existingMappingId; + + public UntisSlotMatch Match { get; } + public string WeekdayLabel { get; } + public string TimeLabel { get; } + public string PatternLabel { get; } + public bool IsConfident => Match.IsConfident; + public bool IsSupervisionCandidate => Match.IsSupervisionCandidate; + public bool CanResolve => IsSupervisionCandidate ? Match.AfterPeriod is not null : Match.PeriodNumber is not null; + + [ObservableProperty] private UntisGroupOption? _selectedGroup; + [ObservableProperty] private bool _confirmAsSupervision; + + public UntisMappingRow(UntisSlotMatch match, List groups, UntisSlotMapping? existing) + { + Match = match; + _existingMappingId = existing?.Id; + WeekdayLabel = match.Pattern.Weekday switch + { + DayOfWeek.Monday => "Mo", DayOfWeek.Tuesday => "Di", DayOfWeek.Wednesday => "Mi", + DayOfWeek.Thursday => "Do", DayOfWeek.Friday => "Fr", + DayOfWeek.Saturday => "Sa", _ => "So", + }; + TimeLabel = IsSupervisionCandidate + ? $"{match.Pattern.StartTime:HH:mm}–{match.Pattern.EndTime:HH:mm} (Pause nach Stunde {match.AfterPeriod})" + : match.CoveredPeriods.Count > 1 + ? $"{match.CoveredPeriods[0]}.–{match.CoveredPeriods[^1]}. Stunde ({match.Pattern.StartTime:HH:mm}, Doppelstunde)" + : match.PeriodNumber is { } period + ? $"{period}. Stunde ({match.Pattern.StartTime:HH:mm})" + : $"{match.Pattern.StartTime:HH:mm} (keine passende Stunde im Stundenraster)"; + PatternLabel = IsSupervisionCandidate + ? "Aufsicht / Springstunde (kein Klassenbezug)" + : match.Pattern.ClassTokens.Count == 0 + ? (match.Pattern.Summary ?? "(ohne Fach)") + : $"{match.Pattern.Summary ?? "?"} · {string.Join(", ", match.Pattern.ClassTokens)}"; + + if (IsSupervisionCandidate) + { + _confirmAsSupervision = existing is { Confirmed: true, Kind: SubstitutionKind.Supervision }; + } + else + { + _selectedGroup = existing is { Confirmed: true, Kind: SubstitutionKind.Lesson, GroupId: { } existingGroupId } + ? groups.FirstOrDefault(g => g.Group.Id == existingGroupId) + : match.SuggestedGroupId is { } suggestedId ? groups.FirstOrDefault(g => g.Group.Id == suggestedId) : null; + } + } + + /// null, wenn diese Zeile nicht (mehr) bestätigt ist — wird beim Speichern übersprungen. + internal UntisSlotMapping? BuildMapping() + { + if (IsSupervisionCandidate) + { + if (!ConfirmAsSupervision || Match.AfterPeriod is null) return null; + return new UntisSlotMapping + { + Id = _existingMappingId ?? Guid.NewGuid(), + Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime, + Summary = Match.Pattern.Summary, ClassToken = "", + Kind = SubstitutionKind.Supervision, AfterPeriod = Match.AfterPeriod, + Confirmed = true, + }; + } + + if (SelectedGroup is null || Match.PeriodNumber is null) return null; + return new UntisSlotMapping + { + Id = _existingMappingId ?? Guid.NewGuid(), + Weekday = Match.Pattern.Weekday, StartTime = Match.Pattern.StartTime, + Summary = Match.Pattern.Summary, ClassToken = string.Join(";", Match.Pattern.ClassTokens), + Kind = SubstitutionKind.Lesson, GroupId = SelectedGroup.Group.Id, PeriodNumber = Match.PeriodNumber, + CoveredPeriods = Match.CoveredPeriods, Confirmed = true, + }; + } +} + +public sealed class UntisGroupOption(LearningGroup group, string? subjectName) +{ + public LearningGroup Group { get; } = group; + public string DisplayLabel { get; } = string.IsNullOrEmpty(subjectName) ? group.Name : $"{group.Name} ({subjectName})"; +} diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index bfb8c2e..87ce4f7 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -36,8 +36,9 @@ public enum SettingsTab Privacy = 10, Sync = 11, Ai = 12, - Appearance = 13, - Trash = 14, + WebUntis = 13, + Appearance = 14, + Trash = 15, } // ── Haupt-ViewModel ─────────────────────────────────────────────────────────── @@ -191,6 +192,16 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private bool _aiIsLoggedIn; [ObservableProperty] private string _aiBalanceDisplay = ""; + // ── WebUntis-iCal-Abgleich (Nutzer-Feedback) ────────────────────────────── + + [ObservableProperty] private bool _untisEnabled; + [ObservableProperty] private bool _untisIsConfigured; + [ObservableProperty] private string _untisIcalUrlInput = ""; + [ObservableProperty] private string _untisUrlError = ""; + [ObservableProperty] private string _untisStatusDisplay = ""; + [ObservableProperty] private bool _untisFetchBusy; + public Func? OnReviewUntisMapping { get; set; } + // ── Synchronisation (Kapitel 10) ────────────────────────────────────────── [ObservableProperty] private string _syncServerUrl = ""; @@ -268,6 +279,8 @@ public partial class SettingsViewModel : ObservableObject private readonly ISupervisionDutyRepository _supervisionDuties; private readonly AiSettingsService _aiSettings; private readonly AiPlanningService _aiPlanning; + private readonly WebUntisSettingsService _untisSettings; + private readonly UntisSyncService? _untisSync; private readonly SyncSettingsService _syncSettings; private readonly SyncAuthService _syncAuth; private readonly EventQueue _eventQueue; @@ -289,10 +302,12 @@ public partial class SettingsViewModel : ObservableObject SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, AiSettingsService aiSettings, AiPlanningService aiPlanning, + WebUntisSettingsService untisSettings, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, AppearanceSettingsService appearance, TrashViewModel trashTab, - SnapshotService? snapshotService = null, SyncEngine? syncEngine = null) + SnapshotService? snapshotService = null, SyncEngine? syncEngine = null, + UntisSyncService? untisSync = null) { _logger = logger; _syncKeyRecovery = syncKeyRecovery; @@ -320,6 +335,8 @@ public partial class SettingsViewModel : ObservableObject _letterTemplates = letterTemplates; _aiSettings = aiSettings; _aiPlanning = aiPlanning; + _untisSettings = untisSettings; + _untisSync = untisSync; _syncSettings = syncSettings; _syncAuth = syncAuth; _eventQueue = eventQueue; @@ -342,6 +359,7 @@ public partial class SettingsViewModel : ObservableObject LoadSupervisionDuties(); LoadLetterTemplates(); LoadAiSettings(); + LoadUntisSettings(); LoadSyncSettings(); LoadSyncConflicts(); } @@ -485,6 +503,60 @@ public partial class SettingsViewModel : ObservableObject AiBalanceDisplay = ""; } + // ── WebUntis-iCal-Abgleich: Laden / Speichern / Entfernen / Jetzt abrufen ──── + // + // Wie bei Sync deckt ein Neustart das Registrieren von UntisSyncService ab + // (AppBootstrapper registriert es nur einmalig beim Start, wenn URL+Enabled vorliegen). + + private void LoadUntisSettings() + { + UntisEnabled = _untisSettings.Enabled; + UntisIsConfigured = _untisSettings.IsConfigured; + UntisStatusDisplay = _untisSettings.LastSyncAt is { } at + ? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_untisSettings.LastSyncStatus}" + : "Noch kein Abgleich durchgeführt."; + } + + [RelayCommand] + private void UntisSaveUrl() + { + UntisUrlError = ""; + if (string.IsNullOrWhiteSpace(UntisIcalUrlInput)) { UntisUrlError = "iCal-URL erforderlich."; return; } + if (!Uri.TryCreate(UntisIcalUrlInput, UriKind.Absolute, out _)) { UntisUrlError = "Ungültige URL."; return; } + + _untisSettings.SetIcalUrl(UntisIcalUrlInput.Trim()); + _untisSettings.SetEnabled(true); + UntisIcalUrlInput = ""; + AppBootstrapper.RestartApplication(); + } + + [RelayCommand] + private void UntisRemove() + { + _untisSettings.ClearIcalUrl(); + LoadUntisSettings(); + AppBootstrapper.RestartApplication(); + } + + [RelayCommand] + private async Task UntisFetchNow() + { + if (_untisSync is null) { UntisStatusDisplay = "Abgleich nicht aktiv — App neu starten."; return; } + UntisFetchBusy = true; + try + { + await _untisSync.PollAsync(); + LoadUntisSettings(); + } + finally { UntisFetchBusy = false; } + } + + [RelayCommand] + private async Task UntisReviewMapping() + { + if (OnReviewUntisMapping is not null) await OnReviewUntisMapping(); + } + // ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ─────── // // Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen diff --git a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml index b949221..a0f6cd9 100644 --- a/LehrerApp.Desktop/Views/Planning/TimetableView.axaml +++ b/LehrerApp.Desktop/Views/Planning/TimetableView.axaml @@ -23,11 +23,22 @@ - + - + + + + + +