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