WIP (unstable): WebUntis-iCal-Abgleich für Vertretungen/Ausfälle

Erkennt Vertretungen, Ausfälle und Zusatzaufsichten aus dem persönlichen
WebUntis-iCal-Feed und schreibt sie automatisch als SubstitutionEntry.
Bekannter offener Bug: es tauchen weiterhin falsche Vertretungen für
Stunden auf, die real unverändert sind — wird in einem Folge-Commit
untersucht, deshalb vorerst auf diesem Branch statt main.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 13:30:15 +02:00
co-authored by Claude Sonnet 5
parent e65a729f97
commit 4eb4d0a946
30 changed files with 3333 additions and 8 deletions
+20
View File
@@ -165,6 +165,8 @@ public static class AppBootstrapper
services.AddSingleton<ISchoolHolidayRepository, SchoolHolidayRepository>();
services.AddSingleton<ISupervisionDutyRepository, SupervisionDutyRepository>();
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
// ── Services ──────────────────────────────────────────────────────────
services.AddSingleton<GradingService>();
@@ -186,6 +188,24 @@ public static class AppBootstrapper
services.AddSingleton(_ => new HttpClient { BaseAddress = new Uri(AiBackendUrl) });
services.AddSingleton<AiPlanningService>();
// ── WebUntis-iCal-Abgleich (optional nur wenn URL hinterlegt und aktiviert) ─────────
var untisSettings = new WebUntisSettingsService(appData);
services.AddSingleton(untisSettings);
services.AddSingleton<UntisMatchingService>();
services.AddSingleton<UntisDiffService>();
if (untisSettings.Enabled && !string.IsNullOrEmpty(untisSettings.GetIcalUrl()))
{
services.AddSingleton(sp => new UntisSyncService(
new HttpClient(), untisSettings,
sp.GetRequiredService<IUntisSnapshotRepository>(), sp.GetRequiredService<IUntisSlotMappingRepository>(),
sp.GetRequiredService<ISubstitutionEntryRepository>(), sp.GetRequiredService<IGroupRepository>(),
sp.GetRequiredService<ITimetableSlotRepository>(), sp.GetRequiredService<ISupervisionDutyRepository>(),
sp.GetRequiredService<ISchoolHolidayRepository>(), sp.GetRequiredService<PublicHolidayService>(),
sp.GetRequiredService<SchoolCalendarSettingsService>(), sp.GetRequiredService<PeriodScheduleService>(),
sp.GetRequiredService<UntisMatchingService>(), sp.GetRequiredService<UntisDiffService>(),
sp.GetRequiredService<AppLogger>()));
}
// ── Sync (optional nur wenn Server konfiguriert) ────────────────────
var syncSettings = new SyncSettingsService(appData);
services.AddSingleton(syncSettings);
@@ -0,0 +1,187 @@
using LehrerApp.Core.Interfaces;
using LehrerApp.Core.Models;
using LehrerApp.Core.Services;
namespace LehrerApp.Desktop.Services;
/// <summary>Ergebnis eines einzelnen Verarbeitungsdurchlaufs (Abruf oder Test-Text) — für
/// Statusanzeige/Logging.</summary>
public sealed record UntisPollResult(int EventCount, int SubstitutionCount);
/// <summary>Für den Zuordnungs-Review-Dialog: das Rohergebnis der Musteranalyse (Stufe 1) für
/// einen konkreten Abruf, ohne dass dabei schon etwas gespeichert wird.</summary>
public sealed record UntisMatchPreview(int EventCount, UntisMatchResult Matches);
/// <summary>
/// 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 (<see cref="ProcessIcsText"/>) 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.
/// </summary>
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<DateOnly> BuildFreeDates(DateOnly today)
{
var horizonEnd = today.AddDays(90);
var freeDates = new HashSet<DateOnly>();
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<UntisMatchPreview?> FetchAndBuildMatchPreviewAsync()
{
var url = _settings.GetIcalUrl();
if (string.IsNullOrEmpty(url)) return null;
var icsText = await _http.GetStringAsync(url);
return BuildMatchPreview(icsText);
}
public void ConfirmMappings(IEnumerable<UntisSlotMapping> confirmed)
{
foreach (var mapping in confirmed)
{
mapping.Confirmed = true;
_mappings.Save(mapping);
}
}
public void Dispose()
{
_timer.Dispose();
_gate.Dispose();
}
}
@@ -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; } = "";
}
/// <summary>
/// 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 <see cref="SyncCrypto"/> 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.
/// </summary>
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<string>(_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<WebUntisSettingsConfig>(File.ReadAllText(_configPath))
?? new WebUntisSettingsConfig();
}
catch { /* beschädigte Konfiguration -> Standardwert */ }
return new WebUntisSettingsConfig();
}
}
@@ -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<TimetableCellItem> Cells { get; } = [];
public ObservableCollection<WeekCellItem> 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<TimetableCellItem, Task>? OnEditSlot { get; set; }
public Action<Guid>? OnNavigateToGroup { get; set; }
public Func<Task>? 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) ────────────────────────────────────
/// <summary>
@@ -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;
/// <summary>
/// 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).
/// </summary>
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<UntisGroupOption> Groups { get; }
public ObservableCollection<UntisMappingRow> Rows { get; } = [];
public ObservableCollection<string> 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<LearningGroup> 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<UntisGroupOption> 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})";
}
@@ -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<Task>? 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
@@ -23,11 +23,22 @@
</Style>
</UserControl.Styles>
<Grid RowDefinitions="Auto,*">
<Grid RowDefinitions="Auto,Auto,*">
<shared:PageHeader Grid.Row="0" Margin="32,28,32,0" Title="Stundenplan"
Subtitle="Wiederkehrendes wöchentliches Muster, keine konkreten Termine"/>
<TabbedPage Grid.Row="1" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<!-- WebUntis-Abweichung (Nutzer-Feedback: "oder der Stundenplan gar nicht mehr passt") -->
<Border Grid.Row="1" Margin="32,12,32,0" Padding="12,8" CornerRadius="6"
Background="#332196F3" IsVisible="{Binding HasUntisMismatch}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="⚠" FontSize="14" Margin="0,0,8,0" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding UntisMismatchLabel}" FontSize="12" VerticalAlignment="Center"/>
<Button Grid.Column="2" Content="Prüfen…" FontSize="11" Padding="8,3"
Command="{Binding ReviewUntisMismatchCommand}"/>
</Grid>
</Border>
<TabbedPage Grid.Row="2" TabPlacement="Top" SelectedIndex="{Binding ActiveTabIndex}">
<!-- Tab: Heute (Standardansicht) -->
<ContentPage Header="Heute">
@@ -0,0 +1,88 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Planning"
x:Class="LehrerApp.Desktop.Views.Planning.UntisMappingReviewDialog"
x:DataType="vm:UntisMappingReviewDialogViewModel"
Title="WebUntis-Zuordnung prüfen"
Width="560" Height="620" MinWidth="480" MinHeight="440"
CanResize="True" WindowStartupLocation="CenterOwner">
<Window.Styles>
<Style Selector="TextBlock.hint">
<Setter Property="FontSize" Value="11"/>
<Setter Property="Opacity" Value="0.6"/>
</Style>
</Window.Styles>
<Grid RowDefinitions="*,Auto" Margin="24">
<ScrollViewer Grid.Row="0">
<StackPanel Spacing="14" Margin="0,0,12,0">
<TextBlock Text="WebUntis-Zuordnung prüfen" Classes="dialogtitle"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Erkannte reguläre Wochenmuster aus dem iCal-Feed — nur bestätigte Zeilen (mit ausgewählter Gruppe) lösen künftig automatisch erkannte Vertretungen/Ausfälle aus."/>
<TextBlock Text="Abgleich läuft…" IsVisible="{Binding IsLoading}" FontSize="13"/>
<TextBlock Text="{Binding ErrorMessage}" Foreground="Red" FontSize="12"
IsVisible="{Binding ErrorMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<ItemsControl ItemsSource="{Binding Rows}" IsVisible="{Binding !IsLoading}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UntisMappingRow">
<Border Background="{DynamicResource SystemControlBackgroundAltHighBrush}"
CornerRadius="6" Padding="10,8" Margin="0,0,0,6">
<Grid ColumnDefinitions="Auto,*,Auto">
<StackPanel Grid.Column="0" Width="60">
<TextBlock Text="{Binding WeekdayLabel}" FontWeight="SemiBold" FontSize="13"/>
<TextBlock Text="{Binding TimeLabel}" Classes="hint" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Column="1" Margin="8,0" VerticalAlignment="Center">
<TextBlock Text="{Binding PatternLabel}" FontSize="13"/>
<TextBlock Text="Mehrdeutig — bitte Gruppe wählen oder ignorieren" Classes="hint"
Foreground="#F59E0B"
IsVisible="{Binding !IsConfident}"/>
</StackPanel>
<ComboBox Grid.Column="2" Width="190" VerticalAlignment="Center"
IsVisible="{Binding !IsSupervisionCandidate}"
ItemsSource="{Binding $parent[ItemsControl].((vm:UntisMappingReviewDialogViewModel)DataContext).Groups}"
SelectedItem="{Binding SelectedGroup}"
IsEnabled="{Binding CanResolve}"
PlaceholderText="Ignorieren">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:UntisGroupOption">
<TextBlock Text="{Binding DisplayLabel}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<CheckBox Grid.Column="2" Content="Als Aufsicht bestätigen" VerticalAlignment="Center"
IsVisible="{Binding IsSupervisionCandidate}"
IsChecked="{Binding ConfirmAsSupervision}"
IsEnabled="{Binding CanResolve}"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="Keine regulären Wochenmuster im Feed gefunden." Classes="emptyhint"
IsVisible="{Binding HasNoRows}"/>
<StackPanel Spacing="4" IsVisible="{Binding HasUnmatchedSlots}">
<Separator Margin="0,4"/>
<TextBlock Text="Stundenplan-Einträge ohne passendes WebUntis-Muster:" FontSize="12" FontWeight="SemiBold"/>
<ItemsControl ItemsSource="{Binding UnmatchedSlotLabels}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding}" FontSize="12" Opacity="0.75" Margin="0,2"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
<Button Grid.Column="2" Content="Speichern" HorizontalAlignment="Stretch" Click="OnSave"
IsEnabled="{Binding !IsLoading}"/>
</Grid>
</Grid>
</Window>
@@ -0,0 +1,27 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Planning;
namespace LehrerApp.Desktop.Views.Planning;
public partial class UntisMappingReviewDialog : Window
{
public UntisMappingReviewDialog() => InitializeComponent();
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is UntisMappingReviewDialogViewModel vm) _ = vm.LoadAsync();
}
private void OnSave(object? s, RoutedEventArgs e)
{
if (DataContext is UntisMappingReviewDialogViewModel vm && vm.SaveCommand.CanExecute(null))
{
vm.SaveCommand.Execute(null);
Close(true);
}
}
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
}
@@ -921,6 +921,41 @@
</ScrollViewer>
</ContentPage>
<!-- Tab: WebUntis-iCal-Abgleich (Nutzer-Feedback) -->
<ContentPage Header="Stundenplan-Abgleich">
<ScrollViewer>
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
Text="Ruft periodisch den persönlichen iCal-Export von WebUntis ab, um Vertretungen, Ausfälle und Raumänderungen automatisch zu erkennen und in den Stundenplan zu übernehmen. Der Link enthält ein Zugangs-Token und wird verschlüsselt gespeichert."/>
<StackPanel Spacing="8" IsVisible="{Binding !UntisIsConfigured}">
<StackPanel Spacing="4">
<TextBlock Text="iCal-URL" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding UntisIcalUrlInput}" PasswordChar="●"
PlaceholderText="https://…/WebUntis/ical_export?..."/>
</StackPanel>
<TextBlock Text="{Binding UntisUrlError}" Foreground="Red" FontSize="12"
IsVisible="{Binding UntisUrlError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Button Content="Speichern und aktivieren" Command="{Binding UntisSaveUrlCommand}" HorizontalAlignment="Left"/>
</StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding UntisIsConfigured}">
<TextBlock Text="iCal-URL hinterlegt (verschlüsselt gespeichert)." FontSize="13" FontWeight="SemiBold"/>
<TextBlock Text="{Binding UntisStatusDisplay}" FontSize="12" Opacity="0.7"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Jetzt abrufen" Command="{Binding UntisFetchNowCommand}"
IsEnabled="{Binding !UntisFetchBusy}"/>
<Button Content="Zuordnung prüfen…" Command="{Binding UntisReviewMappingCommand}"/>
<Button Content="Entfernen" Command="{Binding UntisRemoveCommand}"/>
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ContentPage>
<!-- Tab: Darstellung (12.4) -->
<ContentPage Header="Darstellung">
<ScrollViewer>
@@ -1,8 +1,12 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using LehrerApp.Core.Interfaces;
using LehrerApp.Desktop.Services;
using LehrerApp.Desktop.ViewModels;
using LehrerApp.Desktop.ViewModels.Planning;
using LehrerApp.Desktop.ViewModels.Settings;
using LehrerApp.Desktop.Views.Planning;
using LehrerApp.Desktop.Views.Shared;
using Microsoft.Extensions.DependencyInjection;
@@ -26,9 +30,26 @@ public partial class SettingsView : UserControl
vm.OnPickRecoveryFile = PickRecoveryFile;
vm.OnConfirmRecoveryRestore = ShowRecoveryRestoreConfirmDialog;
vm.OnThemeChanged = App.ApplyTheme;
vm.OnReviewUntisMapping = ShowUntisMappingReviewDialog;
}
}
private async Task ShowUntisMappingReviewDialog()
{
var owner = TopLevel.GetTopLevel(this) as Window;
var untisSync = App.Services.GetService<UntisSyncService>();
if (owner is null || untisSync is null) return;
var groups = App.Services.GetRequiredService<IGroupRepository>().GetAll();
var mappings = App.Services.GetRequiredService<LehrerApp.Core.Interfaces.IUntisSlotMappingRepository>();
var subjects = App.Services.GetRequiredService<LehrerApp.Core.Interfaces.ISubjectRepository>();
var dialog = new UntisMappingReviewDialog
{
DataContext = new UntisMappingReviewDialogViewModel(untisSync, mappings, groups, subjects),
};
await dialog.ShowDialog<bool>(owner);
}
private async Task<bool> SaveRecoveryFile(string content)
{
var topLevel = TopLevel.GetTopLevel(this);