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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user