diff --git a/LehrerApp.Core/Interfaces/IRepositories.cs b/LehrerApp.Core/Interfaces/IRepositories.cs index f4dcbc1..bacc1ef 100644 --- a/LehrerApp.Core/Interfaces/IRepositories.cs +++ b/LehrerApp.Core/Interfaces/IRepositories.cs @@ -143,6 +143,17 @@ public interface IUntisSlotMappingRepository void Save(UntisSlotMapping mapping); void Delete(Guid id); } +/// Lokal importierter, rein informativer Schuljahresplan. Bewusst getrennt von WebUntis- +/// Stundenplanabweichungen und nicht über den App-Sync repliziert; jedes Gerät bezieht denselben +/// externen Feed selbst. +public interface IAnnualPlanEventRepository +{ + List GetAll(); + List GetByRange(DateOnly from, DateOnly to); + AnnualPlanEvent? GetByExternalId(string externalId); + void Save(AnnualPlanEvent entry); + void Delete(Guid id); +} public interface IDocumentationRepository { List GetByStudent(Guid studentId); diff --git a/LehrerApp.Core/Models/AnnualPlan.cs b/LehrerApp.Core/Models/AnnualPlan.cs new file mode 100644 index 0000000..a54e7d4 --- /dev/null +++ b/LehrerApp.Core/Models/AnnualPlan.cs @@ -0,0 +1,37 @@ +namespace LehrerApp.Core.Models; + +/// +/// Ein aus dem schulweiten Jahresplan importierter Termin. Anders als +/// und ist er reine Kalenderinformation: Er verändert weder den +/// regulären Stundenplan noch erzeugt er Vertretungen oder Ausfälle. +/// +public class AnnualPlanEvent +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// Stabile UID aus dem iCal-Feed; fachlicher Schlüssel für idempotente Importe. + public string ExternalId { get; set; } = ""; + + public string Title { get; set; } = ""; + public string Description { get; set; } = ""; + public string Location { get; set; } = ""; + + /// Erster beziehungsweise letzter tatsächlich belegter lokaler Kalendertag. + public DateOnly StartDate { get; set; } + public DateOnly EndDate { get; set; } + + /// + /// Bei Ganztagsterminen null. Zeitgebundene UTC-Werte aus dem Feed werden beim Import in die + /// lokale Schulzeit Europe/Berlin umgerechnet. + /// + public TimeOnly? StartTime { get; set; } + public TimeOnly? EndTime { get; set; } + public bool IsAllDay { get; set; } + + /// ClassyPlan-Gruppe, z.B. "Lehrkräfte" oder "Öffentlich". + public string CalendarGroup { get; set; } = ""; + public string Color { get; set; } = ""; + public string Status { get; set; } = "CONFIRMED"; + public int Sequence { get; set; } + public DateTime? SourceLastModifiedUtc { get; set; } +} diff --git a/LehrerApp.Core/Services/AnnualPlanIcsParser.cs b/LehrerApp.Core/Services/AnnualPlanIcsParser.cs new file mode 100644 index 0000000..37d5cd8 --- /dev/null +++ b/LehrerApp.Core/Services/AnnualPlanIcsParser.cs @@ -0,0 +1,205 @@ +using System.Globalization; +using System.Text; +using LehrerApp.Core.Models; + +namespace LehrerApp.Core.Services; + +/// +/// Parser für den beobachteten ClassyPlan-Jahresplanexport. Im Gegensatz zum bewusst schmalen +/// WebUntis-Parser unterstützt er insbesondere DATE-Ganztagstermine und mehrtägige Ereignisse. +/// Er arbeitet absichtlich strikt: Ist ein VEVENT unvollständig oder ungültig, scheitert der +/// gesamte Parse, damit ein nachgelagerter Snapshot-Abgleich keine bestehenden Termine löscht. +/// +public static class AnnualPlanIcsParser +{ + private static readonly TimeZoneInfo BerlinTimeZone = + TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"); + + private readonly record struct Property(string Head, string Value) + { + public bool IsDate => Head.Contains("VALUE=DATE", StringComparison.OrdinalIgnoreCase); + } + + public static List Parse(string icsText) + { + var lines = Unfold(icsText); + if (!lines.Any(l => l.Equals("BEGIN:VCALENDAR", StringComparison.OrdinalIgnoreCase)) || + !lines.Any(l => l.Equals("END:VCALENDAR", StringComparison.OrdinalIgnoreCase))) + throw new FormatException("Kein vollständiger iCal-Kalender gefunden."); + + var result = new List(); + Dictionary? current = null; + + foreach (var line in lines) + { + if (line.Equals("BEGIN:VEVENT", StringComparison.OrdinalIgnoreCase)) + { + if (current is not null) throw new FormatException("Verschachteltes VEVENT gefunden."); + current = new Dictionary(StringComparer.OrdinalIgnoreCase); + continue; + } + + if (line.Equals("END:VEVENT", StringComparison.OrdinalIgnoreCase)) + { + if (current is null) throw new FormatException("END:VEVENT ohne BEGIN:VEVENT gefunden."); + result.Add(BuildEvent(current, result.Count + 1)); + current = null; + continue; + } + + if (current is null) continue; + var colon = line.IndexOf(':'); + if (colon < 0) continue; + var head = line[..colon]; + var separator = head.IndexOf(';'); + var key = separator < 0 ? head : head[..separator]; + if (key.Length > 0) current[key] = new Property(head, line[(colon + 1)..]); + } + + if (current is not null) throw new FormatException("Nicht abgeschlossenes VEVENT gefunden."); + if (result.Select(e => e.ExternalId).Distinct(StringComparer.Ordinal).Count() != result.Count) + throw new FormatException("Der Jahresplan enthält doppelte iCal-UIDs."); + return result; + } + + private static AnnualPlanEvent BuildEvent(Dictionary properties, int number) + { + var uid = Required(properties, "UID", number).Value.Trim(); + if (uid.Length == 0) throw Invalid(number, "UID fehlt"); + + var startProperty = Required(properties, "DTSTART", number); + var endProperty = properties.GetValueOrDefault("DTEND"); + + DateOnly startDate; + DateOnly endDate; + TimeOnly? startTime; + TimeOnly? endTime; + var isAllDay = startProperty.IsDate; + + if (isAllDay) + { + startDate = ParseDate(startProperty.Value, number, "DTSTART"); + var endExclusive = endProperty == default + ? startDate.AddDays(1) + : endProperty.IsDate + ? ParseDate(endProperty.Value, number, "DTEND") + : throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen"); + if (endExclusive <= startDate) throw Invalid(number, "DTEND liegt nicht nach DTSTART"); + endDate = endExclusive.AddDays(-1); // RFC 5545: DTEND eines Ganztagstermins ist exklusiv. + startTime = null; + endTime = null; + } + else + { + var start = ParseLocalDateTime(startProperty, number, "DTSTART"); + var end = endProperty == default + ? start + : !endProperty.IsDate + ? ParseLocalDateTime(endProperty, number, "DTEND") + : throw Invalid(number, "DTSTART und DTEND verwenden unterschiedliche Werttypen"); + if (end < start) throw Invalid(number, "DTEND liegt vor DTSTART"); + startDate = DateOnly.FromDateTime(start); + endDate = DateOnly.FromDateTime(end); + startTime = TimeOnly.FromDateTime(start); + endTime = TimeOnly.FromDateTime(end); + } + + return new AnnualPlanEvent + { + ExternalId = uid, + Title = Text(properties, "SUMMARY"), + Description = Text(properties, "DESCRIPTION"), + Location = Text(properties, "LOCATION"), + StartDate = startDate, + EndDate = endDate, + StartTime = startTime, + EndTime = endTime, + IsAllDay = isAllDay, + CalendarGroup = Text(properties, "X-GROUPNAME"), + Color = Text(properties, "X-COLOR"), + Status = Text(properties, "STATUS") is { Length: > 0 } status ? status : "CONFIRMED", + Sequence = int.TryParse(Raw(properties, "SEQUENCE"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out var sequence) ? sequence : 0, + SourceLastModifiedUtc = ParseOptionalUtc(properties.GetValueOrDefault("LAST-MODIFIED"), number), + }; + } + + private static Property Required(Dictionary properties, string key, int number) => + properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt"); + + private static string Raw(Dictionary properties, string key) => + properties.TryGetValue(key, out var value) ? value.Value : ""; + + private static string Text(Dictionary properties, string key) => + Unescape(Raw(properties, key)); + + private static DateOnly ParseDate(string value, int number, string field) + { + if (DateOnly.TryParseExact(value, "yyyyMMdd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var date)) return date; + throw Invalid(number, $"ungültiges {field}: {value}"); + } + + private static DateTime ParseLocalDateTime(Property property, int number, string field) + { + var utc = property.Value.EndsWith('Z'); + var value = utc ? property.Value[..^1] : property.Value; + var formats = new[] { "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmm" }; + if (!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture, + DateTimeStyles.None, out var parsed)) + throw Invalid(number, $"ungültiges {field}: {property.Value}"); + + return utc + ? TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(parsed, DateTimeKind.Utc), BerlinTimeZone) + : DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified); + } + + private static DateTime? ParseOptionalUtc(Property property, int number) + { + if (property == default || string.IsNullOrWhiteSpace(property.Value)) return null; + var local = ParseLocalDateTime(property, number, "LAST-MODIFIED"); + return property.Value.EndsWith('Z') + ? TimeZoneInfo.ConvertTimeToUtc(local, BerlinTimeZone) + : TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(local, DateTimeKind.Unspecified), BerlinTimeZone); + } + + private static List Unfold(string text) + { + var result = new List(); + foreach (var line in text.Replace("\r\n", "\n").Replace("\r", "\n").Split('\n')) + { + if ((line.StartsWith(' ') || line.StartsWith('\t')) && result.Count > 0) + result[^1] += line[1..]; + else + result.Add(line); + } + return result; + } + + private static string Unescape(string value) + { + var result = new StringBuilder(value.Length); + for (var i = 0; i < value.Length; i++) + { + if (value[i] != '\\' || i + 1 >= value.Length) + { + result.Append(value[i]); + continue; + } + + var next = value[++i]; + result.Append(next switch + { + 'n' or 'N' => '\n', + '\\' => '\\', + ',' => ',', + ';' => ';', + _ => next, + }); + } + return result.ToString(); + } + + private static FormatException Invalid(int number, string message) => + new($"Ungültiger Jahresplan-Termin #{number}: {message}."); +} diff --git a/LehrerApp.Data.Tests/RepositoryTests.cs b/LehrerApp.Data.Tests/RepositoryTests.cs index 498cd86..9ee47d3 100644 --- a/LehrerApp.Data.Tests/RepositoryTests.cs +++ b/LehrerApp.Data.Tests/RepositoryTests.cs @@ -1028,6 +1028,31 @@ public sealed class RepositoryTests Assert.Empty(repo.GetAll()); } + // ── AnnualPlanEventRepository ──────────────────────────────────────────── + + [Fact] + public void AnnualPlanEventRepository_GetByRange_FindetAuchUeberlappendeMehrtagstermine() + { + using var db = NewInMemoryContext(); + var repo = new AnnualPlanEventRepository(db); + repo.Save(new AnnualPlanEvent + { + ExternalId = "fahrt", Title = "Klassenfahrt", + StartDate = new DateOnly(2026, 6, 15), EndDate = new DateOnly(2026, 6, 19), IsAllDay = true, + }); + repo.Save(new AnnualPlanEvent + { + ExternalId = "spaeter", Title = "Sommerfest", + StartDate = new DateOnly(2026, 7, 1), EndDate = new DateOnly(2026, 7, 1), IsAllDay = true, + }); + + var result = repo.GetByRange(new DateOnly(2026, 6, 17), new DateOnly(2026, 6, 17)); + + Assert.Single(result); + Assert.Equal("fahrt", result[0].ExternalId); + Assert.Equal("fahrt", repo.GetByExternalId("fahrt")!.ExternalId); + } + // ── WorkTaskRepository ──────────────────────────────────────────────────── [Fact] diff --git a/LehrerApp.Data/LiteDbContext.cs b/LehrerApp.Data/LiteDbContext.cs index a0ae4bb..f11e7ad 100644 --- a/LehrerApp.Data/LiteDbContext.cs +++ b/LehrerApp.Data/LiteDbContext.cs @@ -68,6 +68,7 @@ public class LiteDbContext : IDisposable public ILiteCollection SubstitutionEntries => _db.GetCollection("substitution_entries"); public ILiteCollection UntisSnapshotEntries => _db.GetCollection("untis_snapshot_entries"); public ILiteCollection UntisSlotMappings => _db.GetCollection("untis_slot_mappings"); + public ILiteCollection AnnualPlanEvents => _db.GetCollection("annual_plan_events"); public ILiteCollection TrashedItems => _db.GetCollection("trash"); public void Checkpoint() => _db.Checkpoint(); @@ -531,6 +532,9 @@ public class LiteDbContext : IDisposable SupervisionDuties.EnsureIndex("ux_supervision_weekday_period", BsonExpression.Create("STRING($.Weekday) + ':' + STRING($.AfterPeriod)"), unique: true); SubstitutionEntries.EnsureIndex(x => x.Date); + AnnualPlanEvents.EnsureIndex(x => x.ExternalId, unique: true); + AnnualPlanEvents.EnsureIndex(x => x.StartDate); + AnnualPlanEvents.EnsureIndex(x => x.EndDate); } public void Dispose() => _db.Dispose(); diff --git a/LehrerApp.Data/Repositories/AllRepositories.cs b/LehrerApp.Data/Repositories/AllRepositories.cs index 5d83b38..9c4f1fe 100644 --- a/LehrerApp.Data/Repositories/AllRepositories.cs +++ b/LehrerApp.Data/Repositories/AllRepositories.cs @@ -776,6 +776,24 @@ public class UntisSlotMappingRepository(LiteDbContext db) : IUntisSlotMappingRep public void Delete(Guid id) => db.UntisSlotMappings.Delete(id); } +// Wie UntisSnapshotEntry rein lokal: Der Jahresplan ist ein wiederherstellbarer Cache eines +// externen Feeds. db.OnChange würde bei jedem vollständigen Abruf hunderte Sync-Ereignisse erzeugen. +public class AnnualPlanEventRepository(LiteDbContext db) : IAnnualPlanEventRepository +{ + public List GetAll() => + db.AnnualPlanEvents.FindAll().OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList(); + + public List GetByRange(DateOnly from, DateOnly to) => + db.AnnualPlanEvents.Find(e => e.StartDate <= to && e.EndDate >= from) + .OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList(); + + public AnnualPlanEvent? GetByExternalId(string externalId) => + db.AnnualPlanEvents.FindOne(e => e.ExternalId == externalId); + + public void Save(AnnualPlanEvent entry) => db.AnnualPlanEvents.Upsert(entry); + public void Delete(Guid id) => db.AnnualPlanEvents.Delete(id); +} + public class CompetencyDomainRepository(LiteDbContext db) : ICompetencyDomainRepository { public List GetBySubjectAndGrade(Guid subjectId, int gradeLevel) => diff --git a/LehrerApp.Desktop.Tests/AnnualPlanSettingsServiceTests.cs b/LehrerApp.Desktop.Tests/AnnualPlanSettingsServiceTests.cs new file mode 100644 index 0000000..6e24495 --- /dev/null +++ b/LehrerApp.Desktop.Tests/AnnualPlanSettingsServiceTests.cs @@ -0,0 +1,46 @@ +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class AnnualPlanSettingsServiceTests +{ + private const string SampleUrl = "https://example.org/export.php?type=ics&token=intern"; + + private static string TempPath() + { + var path = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettingssvc-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + + [Fact] + public void IcalUrl_WirdVerschluesseltPersistiertUndWiederGeladen() + { + var path = TempPath(); + var service = new AnnualPlanSettingsService(path); + service.SetIcalUrl(SampleUrl); + service.SetEnabled(true); + + var raw = File.ReadAllText(Path.Combine(path, "annual-plan-settings.json")); + var reloaded = new AnnualPlanSettingsService(path); + + Assert.DoesNotContain("token=intern", raw); + Assert.Equal(SampleUrl, reloaded.GetIcalUrl()); + Assert.True(reloaded.Enabled); + } + + [Fact] + public void ClearIcalUrl_EntferntUrlUndDeaktiviertDenImport() + { + var service = new AnnualPlanSettingsService(TempPath()); + service.SetIcalUrl(SampleUrl); + service.SetEnabled(true); + + service.ClearIcalUrl(); + + Assert.False(service.IsConfigured); + Assert.False(service.Enabled); + Assert.Null(service.GetIcalUrl()); + } +} diff --git a/LehrerApp.Desktop.Tests/AnnualPlanSyncServiceTests.cs b/LehrerApp.Desktop.Tests/AnnualPlanSyncServiceTests.cs new file mode 100644 index 0000000..5d623c2 --- /dev/null +++ b/LehrerApp.Desktop.Tests/AnnualPlanSyncServiceTests.cs @@ -0,0 +1,73 @@ +using LehrerApp.Core.Models; +using LehrerApp.Desktop.Services; +using Xunit; + +namespace LehrerApp.Desktop.Tests; + +public sealed class AnnualPlanSyncServiceTests +{ + private static string Calendar(params string[] events) => + "BEGIN:VCALENDAR\nVERSION:2.0\n" + string.Join("", events) + "END:VCALENDAR\n"; + + private static string Event(string uid, string title, string date = "20260823") => + $"BEGIN:VEVENT\nUID:{uid}\nDTSTART;VALUE=DATE:{date}\nSUMMARY:{title}\nEND:VEVENT\n"; + + private static AnnualPlanSyncService Build(FakeAnnualPlanEvents events) => + new(new HttpClient(), TestSupport.BuildAnnualPlanSettingsService(), events); + + [Fact] + public void ProcessIcsText_LegtAn_AktualisiertIdempotent_UndEntferntVerschwundeneTermine() + { + var events = new FakeAnnualPlanEvents(); + using var service = Build(events); + + var first = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe"))); + var originalId = events.GetByExternalId("1")!.Id; + var unchanged = service.ProcessIcsText(Calendar(Event("1", "Konferenz"), Event("2", "Messe"))); + var changed = service.ProcessIcsText(Calendar(Event("1", "Konferenz verschoben", "20260824"))); + + Assert.Equal((2, 0, 0), (first.Added, first.Updated, first.Deleted)); + Assert.Equal(0, unchanged.ChangeCount); + Assert.Equal((0, 1, 1), (changed.Added, changed.Updated, changed.Deleted)); + Assert.Equal(originalId, events.GetByExternalId("1")!.Id); + Assert.Null(events.GetByExternalId("2")); + } + + [Fact] + public void ProcessIcsText_DefekterTeilimport_VeraendertBestehendenBestandNicht() + { + var events = new FakeAnnualPlanEvents(); + events.Add(new AnnualPlanEvent + { + ExternalId = "bestand", Title = "Bestehend", + StartDate = new DateOnly(2026, 8, 23), EndDate = new DateOnly(2026, 8, 23), IsAllDay = true, + }); + using var service = Build(events); + var broken = Calendar(Event("neu", "Neu"), + "BEGIN:VEVENT\nUID:defekt\nSUMMARY:Ohne Datum\nEND:VEVENT\n"); + + Assert.Throws(() => service.ProcessIcsText(broken)); + + var remaining = Assert.Single(events.GetAll()); + Assert.Equal("bestand", remaining.ExternalId); + } + + [Fact] + public void Clear_EntferntDenLokalenFeedCache() + { + var events = new FakeAnnualPlanEvents(); + events.Add(new AnnualPlanEvent + { + ExternalId = "1", StartDate = new DateOnly(2026, 8, 23), + EndDate = new DateOnly(2026, 8, 23), IsAllDay = true, + }); + using var service = Build(events); + var notified = false; + service.DataChanged += () => notified = true; + + service.Clear(); + + Assert.Empty(events.GetAll()); + Assert.True(notified); + } +} diff --git a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs index 4b47da2..098d707 100644 --- a/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/DashboardViewModelTests.cs @@ -42,7 +42,8 @@ public sealed class DashboardViewModelTests FakeWorkTasks? tasks = null, FakeStudents? students = null, FakeDocumentation? documentation = null, DashboardSettingsService? dashboardSettings = null, FakeSchoolHolidays? schoolHolidays = null, FakeLessons? lessons = null, FakeSubstitutionEntries? substitutions = null, - FakeSessions? sessions = null, FakeEntries? entries = null) + FakeSessions? sessions = null, FakeEntries? entries = null, + FakeAnnualPlanEvents? annualPlanEvents = null) { lessons ??= new FakeLessons(); lessons.Add(lesson); @@ -55,7 +56,7 @@ public sealed class DashboardViewModelTests slots ?? new FakeTimetableSlots(), periodSchedule ?? NewPeriodSchedule(), new AttendanceBalanceService(), new SchoolYearService(), dashboardSettings ?? NewDashboardSettings(), schoolHolidays ?? new FakeSchoolHolidays(), new PublicHolidayService(), NewCalendarSettings(), - substitutions ?? new FakeSubstitutionEntries()); + substitutions ?? new FakeSubstitutionEntries(), annualPlanEvents); } [Fact] @@ -244,6 +245,37 @@ public sealed class DashboardViewModelTests e => e.Kind == CalendarEventKind.ParticipationSession && e.Subtitle == "Aufsatz"); } + [Fact] + public void Kalender_ZeigtMehrtaegigenJahresplanParallelZumUnterrichtAn() + { + var today = DateOnly.FromDateTime(DateTime.Today); + var group = new LearningGroup { Name = "9c" }; + var annualPlan = new FakeAnnualPlanEvents(); + annualPlan.Add(new AnnualPlanEvent + { + ExternalId = "fahrt", Title = "Klassenfahrt 6a", + Description = "Aushang beachten", Location = "Jugendherberge", + CalendarGroup = "Lehrkräfte", StartDate = today, EndDate = today.AddDays(2), + StartTime = new TimeOnly(13, 30), EndTime = new TimeOnly(15, 0), IsAllDay = false, + }); + + var vm = BuildVm(group, new Lesson { GroupId = group.Id, Date = today, Topic = "Redox" }, + annualPlanEvents: annualPlan); + + var day = vm.CalendarDays.Single(d => d.Date == today); + Assert.True(day.HasLesson); + Assert.True(day.HasAnnualPlanEvent); + + vm.SelectCalendarDayCommand.Execute(day); + + Assert.Contains(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.Lesson); + var annual = Assert.Single(vm.SelectedDayEvents, e => e.Kind == CalendarEventKind.AnnualPlan); + Assert.Equal("Klassenfahrt 6a", annual.Title); + Assert.Equal("Aushang beachten", annual.Description); + Assert.Contains("13:30", annual.Subtitle); + Assert.Null(annual.GroupId); + } + [Fact] public void Kalender_AusStundeErzeugteSitzungErzeugtKeinenDoppelteintrag() { diff --git a/LehrerApp.Desktop.Tests/Fakes.cs b/LehrerApp.Desktop.Tests/Fakes.cs index 641d45f..0a88552 100644 --- a/LehrerApp.Desktop.Tests/Fakes.cs +++ b/LehrerApp.Desktop.Tests/Fakes.cs @@ -39,6 +39,14 @@ public static class TestSupport return new WebUntisSettingsService(tempPath); } + /// Analog zu den übrigen dateibasierten Feed-Einstellungen: eigenes Temp-Verzeichnis. + public static AnnualPlanSettingsService BuildAnnualPlanSettingsService() + { + var tempPath = Path.Combine(Path.GetTempPath(), $"lehrerapp-annualplansettings-tests-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempPath); + return new AnnualPlanSettingsService(tempPath); + } + /// Analog zu , eigenes Temp-Verzeichnis je Aufruf. public static SyncSettingsService BuildSyncSettingsService() { @@ -453,6 +461,19 @@ public class FakeUntisSlotMappings : IUntisSlotMappingRepository public void Delete(Guid id) => _all.RemoveAll(m => m.Id == id); } +public class FakeAnnualPlanEvents : IAnnualPlanEventRepository +{ + private readonly List _all = []; + public void Add(AnnualPlanEvent e) => _all.Add(e); + public List GetAll() => _all.ToList(); + public List GetByRange(DateOnly from, DateOnly to) => + _all.Where(e => e.StartDate <= to && e.EndDate >= from).ToList(); + public AnnualPlanEvent? GetByExternalId(string externalId) => + _all.FirstOrDefault(e => e.ExternalId == externalId); + public void Save(AnnualPlanEvent entry) { _all.RemoveAll(e => e.Id == entry.Id); _all.Add(entry); } + public void Delete(Guid id) => _all.RemoveAll(e => e.Id == id); +} + public class FakeWorkTasks : IWorkTaskRepository { private readonly List _all = []; diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 4a0b99a..7006aed 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -35,6 +35,7 @@ public sealed class SettingsViewModelTests new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), + TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), eventQueue ?? TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), syncKeyStatus ?? TestSupport.BuildSyncKeyStatus(), @@ -291,6 +292,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), + TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); @@ -317,6 +319,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), + TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); @@ -347,6 +350,7 @@ public sealed class SettingsViewModelTests new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), + TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), TestSupport.BuildAppLogger(), TestSupport.BuildSyncKeyStatus(), TestSupport.BuildSyncKeyRecoveryService(), TestSupport.BuildAppearanceSettingsService(), TestSupport.BuildTrashViewModel()); diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index bfdd8a5..90a2359 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -167,6 +167,7 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // ── Services ────────────────────────────────────────────────────────── services.AddSingleton(); @@ -206,6 +207,17 @@ public static class AppBootstrapper sp.GetRequiredService())); } + // ── Schulweiter Jahresplan (informativer ClassyPlan-iCal, kein Stundenplan-Abgleich) ── + var annualPlanSettings = new AnnualPlanSettingsService(appData); + services.AddSingleton(annualPlanSettings); + if (annualPlanSettings.Enabled && !string.IsNullOrEmpty(annualPlanSettings.GetIcalUrl())) + { + services.AddSingleton(sp => new AnnualPlanSyncService( + new HttpClient(), annualPlanSettings, + sp.GetRequiredService(), + sp.GetRequiredService())); + } + // ── Sync (optional – nur wenn Server konfiguriert) ──────────────────── var syncSettings = new SyncSettingsService(appData); services.AddSingleton(syncSettings); diff --git a/LehrerApp.Desktop/Services/AnnualPlanSettingsService.cs b/LehrerApp.Desktop/Services/AnnualPlanSettingsService.cs new file mode 100644 index 0000000..fa1700c --- /dev/null +++ b/LehrerApp.Desktop/Services/AnnualPlanSettingsService.cs @@ -0,0 +1,87 @@ +using System.Text.Json; +using LehrerApp.Sync.Crypto; + +namespace LehrerApp.Desktop.Services; + +internal sealed class AnnualPlanSettingsConfig +{ + public bool Enabled { get; set; } + public string? EncryptedIcalUrl { get; set; } + public DateTime? LastSyncAt { get; set; } + public string LastSyncStatus { get; set; } = ""; +} + +/// +/// Gerätebezogene Einstellungen für den externen Schuljahresplan. Der eingebettete Feed-Schlüssel +/// wird wie die WebUntis-URL verschlüsselt in einer separaten Datei gespeichert. +/// +public sealed class AnnualPlanSettingsService +{ + private readonly string _configPath; + private readonly byte[] _urlKey; + private AnnualPlanSettingsConfig _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 AnnualPlanSettingsService(string appDataPath) + { + _configPath = Path.Combine(appDataPath, "annual-plan-settings.json"); + var keyPath = Path.Combine(appDataPath, "annual-plan-url.key"); + _urlKey = SyncCrypto.LoadKey(keyPath) ?? GenerateAndSaveKey(keyPath); + _config = Load(); + } + + public void SetIcalUrl(string url) + { + _config.EncryptedIcalUrl = SyncCrypto.EncryptObject(url, _urlKey); + Save(); + } + + public string? GetIcalUrl() => _config.EncryptedIcalUrl is null + ? null + : SyncCrypto.DecryptObject(_config.EncryptedIcalUrl, _urlKey); + + public void SetEnabled(bool enabled) + { + _config.Enabled = enabled; + Save(); + } + + 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(string keyPath) + { + var key = SyncCrypto.GenerateKey(); + SyncCrypto.SaveKey(key, keyPath); + return key; + } + + private AnnualPlanSettingsConfig Load() + { + try + { + if (File.Exists(_configPath)) + return JsonSerializer.Deserialize(File.ReadAllText(_configPath)) + ?? new AnnualPlanSettingsConfig(); + } + catch { /* beschädigte lokale Konfiguration -> Standardwerte */ } + return new AnnualPlanSettingsConfig(); + } + + private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config)); +} diff --git a/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs b/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs new file mode 100644 index 0000000..88f5d81 --- /dev/null +++ b/LehrerApp.Desktop/Services/AnnualPlanSyncService.cs @@ -0,0 +1,147 @@ +using LehrerApp.Core.Interfaces; +using LehrerApp.Core.Models; +using LehrerApp.Core.Services; + +namespace LehrerApp.Desktop.Services; + +public sealed record AnnualPlanPollResult(int EventCount, int Added, int Updated, int Deleted) +{ + public int ChangeCount => Added + Updated + Deleted; +} + +/// +/// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei +/// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories. +/// +public sealed class AnnualPlanSyncService : IDisposable +{ + private static readonly TimeSpan PollInterval = TimeSpan.FromHours(6); + + private readonly HttpClient _http; + private readonly AnnualPlanSettingsService _settings; + private readonly IAnnualPlanEventRepository _events; + private readonly AppLogger? _logger; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly Timer _timer; + + public event Action? DataChanged; + + public AnnualPlanSyncService(HttpClient http, AnnualPlanSettingsService settings, + IAnnualPlanEventRepository events, AppLogger? logger = null) + { + _http = http; + _settings = settings; + _events = events; + _logger = logger; + _timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval); + } + + public async Task PollAsync() + { + if (!await _gate.WaitAsync(0)) return; + try + { + var url = _settings.GetIcalUrl(); + if (string.IsNullOrWhiteSpace(url)) return; + + string icsText; + try + { + icsText = await _http.GetStringAsync(url); + } + catch (Exception ex) + { + _logger?.Error("Jahresplan: Abruf fehlgeschlagen", ex); + _settings.SetLastSync(DateTime.UtcNow, $"Fehler beim Abruf: {ex.Message}"); + return; + } + + AnnualPlanPollResult result; + try + { + result = ProcessIcsText(icsText); + } + catch (Exception ex) + { + _logger?.Error("Jahresplan: Verarbeitung fehlgeschlagen", ex); + _settings.SetLastSync(DateTime.UtcNow, $"Fehler bei der Verarbeitung: {ex.Message}"); + return; + } + + _settings.SetLastSync(DateTime.UtcNow, + $"{result.EventCount} Termine — {result.Added} neu, {result.Updated} geändert, {result.Deleted} entfernt."); + _logger?.Info($"Jahresplan: {result.EventCount} Termine, {result.ChangeCount} Änderung(en)."); + if (result.ChangeCount > 0) DataChanged?.Invoke(); + } + finally + { + _gate.Release(); + } + } + + /// HTTP-freier, vollständig testbarer Snapshot-Abgleich. + public AnnualPlanPollResult ProcessIcsText(string icsText) + { + // Parse muss vollständig erfolgreich sein, bevor das Repository verändert wird. Der + // strikte Parser verhindert so Teilimporte und fälschliches Löschen des Restbestands. + var imported = AnnualPlanIcsParser.Parse(icsText); + var existing = _events.GetAll(); + var existingByExternalId = existing.ToDictionary(e => e.ExternalId, StringComparer.Ordinal); + var importedIds = imported.Select(e => e.ExternalId).ToHashSet(StringComparer.Ordinal); + var added = 0; + var updated = 0; + + foreach (var entry in imported) + { + if (!existingByExternalId.TryGetValue(entry.ExternalId, out var previous)) + { + _events.Save(entry); + added++; + continue; + } + + entry.Id = previous.Id; + if (SameContent(previous, entry)) continue; + _events.Save(entry); + updated++; + } + + var stale = existing.Where(e => !importedIds.Contains(e.ExternalId)).ToList(); + foreach (var entry in stale) _events.Delete(entry.Id); + + return new AnnualPlanPollResult(imported.Count, added, updated, stale.Count); + } + + private static bool SameContent(AnnualPlanEvent left, AnnualPlanEvent right) => + left.ExternalId == right.ExternalId && + left.Title == right.Title && + left.Description == right.Description && + left.Location == right.Location && + left.StartDate == right.StartDate && + left.EndDate == right.EndDate && + left.StartTime == right.StartTime && + left.EndTime == right.EndTime && + left.IsAllDay == right.IsAllDay && + left.CalendarGroup == right.CalendarGroup && + left.Color == right.Color && + left.Status == right.Status && + left.Sequence == right.Sequence && + SameInstant(left.SourceLastModifiedUtc, right.SourceLastModifiedUtc); + + private static bool SameInstant(DateTime? left, DateTime? right) => + left is null && right is null || + left is not null && right is not null && left.Value.ToUniversalTime() == right.Value.ToUniversalTime(); + + public void Clear() + { + var existing = _events.GetAll(); + foreach (var entry in existing) _events.Delete(entry.Id); + if (existing.Count > 0) DataChanged?.Invoke(); + } + + public void Dispose() + { + _timer.Dispose(); + _gate.Dispose(); + } +} diff --git a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs index ebba00a..0349001 100644 --- a/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/DashboardViewModel.cs @@ -3,6 +3,7 @@ using CommunityToolkit.Mvvm.Input; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using LehrerApp.Core.Services; +using LehrerApp.Desktop.Services; using System.Collections.ObjectModel; using System.Globalization; @@ -34,6 +35,7 @@ public partial class DashboardViewModel : ObservableObject private readonly PublicHolidayService _publicHolidays; private readonly SchoolCalendarSettingsService _calendarSettings; private readonly ISubstitutionEntryRepository _substitutions; + private readonly IAnnualPlanEventRepository? _annualPlanEvents; private const int OpenExcuseMaxAgeDays = 21; private const int SupportPlanDueWithinDays = 14; @@ -108,7 +110,8 @@ public partial class DashboardViewModel : ObservableObject PeriodScheduleService periodSchedule, AttendanceBalanceService attendanceBalance, SchoolYearService sy, DashboardSettingsService dashboardSettings, ISchoolHolidayRepository schoolHolidays, PublicHolidayService publicHolidays, SchoolCalendarSettingsService calendarSettings, - ISubstitutionEntryRepository substitutions) + ISubstitutionEntryRepository substitutions, IAnnualPlanEventRepository? annualPlanEvents = null, + AnnualPlanSyncService? annualPlanSync = null) { _groups = groups; _subjects = subjects; _lessons = lessons; _exams = exams; _tasks = tasks; _examResults = examResults; _grades = grades; _reportGrades = reportGrades; _memberships = memberships; @@ -118,6 +121,12 @@ public partial class DashboardViewModel : ObservableObject _attendanceBalance = attendanceBalance; _sy = sy; _dashboardSettings = dashboardSettings; _schoolHolidays = schoolHolidays; _publicHolidays = publicHolidays; _calendarSettings = calendarSettings; _substitutions = substitutions; + _annualPlanEvents = annualPlanEvents; + if (annualPlanSync is not null) + { + annualPlanSync.DataChanged += () => Avalonia.Threading.Dispatcher.UIThread.Post(LoadCalendar); + _ = annualPlanSync.PollAsync(); + } LoadDashboardCards(); Load(); } @@ -580,6 +589,27 @@ public partial class DashboardViewModel : ObservableObject } } + // Der Jahresplan wird lediglich in dieselbe Anzeigeprojektion eingemischt. Er nimmt an + // keiner Stundenplan-/Vertretungslogik teil; mehrtägige Termine erscheinen an jedem + // betroffenen Kalendertag. + if (_annualPlanEvents is not null) + { + foreach (var annualEvent in _annualPlanEvents.GetByRange(gridStart, gridEnd) + .Where(e => !string.Equals(e.Status, "CANCELLED", StringComparison.OrdinalIgnoreCase))) + { + var visibleStart = annualEvent.StartDate < gridStart ? gridStart : annualEvent.StartDate; + var visibleEnd = annualEvent.EndDate > gridEnd ? gridEnd : annualEvent.EndDate; + for (var date = visibleStart; date <= visibleEnd; date = date.AddDays(1)) + { + var agg = Agg(date); + agg.HasAnnualPlanEvent = true; + agg.Details.Add(new CalendarEventItem(CalendarEventKind.AnnualPlan, date, + annualEvent.Title, FormatAnnualPlanSubtitle(annualEvent), null, + annualEvent.Description)); + } + } + } + // "Meine Klasse"-Ring: bisher nur gesetzt, wenn für den Tag schon eine Lesson/Exam/Sitzung // existiert — ein Tag, an dem laut Stundenplan (4.3) eine eigene Klasse ansteht, für den // aber noch keine Lesson angelegt wurde (z.B. "morgen"), zeigte den Ring fälschlich nicht. @@ -615,7 +645,7 @@ public partial class DashboardViewModel : ObservableObject byDay.TryGetValue(date, out var agg); CalendarDays.Add(new CalendarDayCell(date, date.Month == firstOfMonth.Month, date == today, agg?.HasLesson ?? false, agg?.HasExam ?? false, agg?.HasSession ?? false, - agg?.IsOwnClassDay ?? false, agg?.Details ?? [])); + agg?.HasAnnualPlanEvent ?? false, agg?.IsOwnClassDay ?? false, agg?.Details ?? [])); } SelectCalendarDay(CalendarDays.FirstOrDefault(d => d.Date == today && d.IsCurrentMonth) ?? CalendarDays.First(d => d.IsCurrentMonth)); @@ -635,8 +665,9 @@ public partial class DashboardViewModel : ObservableObject private void OpenCalendarEvent(CalendarEventItem? item) { if (item is null) return; - if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(item.GroupId); - else OnNavigateToLesson?.Invoke(item.GroupId); // auch für ParticipationSession: Tab "Mitarbeit" + if (item.GroupId is not { } groupId) return; // Jahresplantermine sind reine Information. + if (item.Kind == CalendarEventKind.Exam) OnNavigateToExam?.Invoke(groupId); + else OnNavigateToLesson?.Invoke(groupId); // auch für ParticipationSession: Tab "Mitarbeit" } [RelayCommand] @@ -699,9 +730,35 @@ public partial class DashboardViewModel : ObservableObject public bool HasLesson; public bool HasExam; public bool HasSession; + public bool HasAnnualPlanEvent; public bool IsOwnClassDay; public List Details { get; } = []; } + + private static string FormatAnnualPlanSubtitle(AnnualPlanEvent entry) + { + string time; + if (entry.IsAllDay) + { + time = entry.StartDate == entry.EndDate + ? "Ganztägig" + : $"{entry.StartDate:dd.MM.}–{entry.EndDate:dd.MM.yyyy} · ganztägig"; + } + else if (entry.StartDate == entry.EndDate) + { + time = entry.EndTime is { } end + ? $"{entry.StartTime:HH\\:mm}–{end:HH\\:mm}" + : $"{entry.StartTime:HH\\:mm}"; + } + else + { + time = $"{entry.StartDate:dd.MM.} {entry.StartTime:HH\\:mm}–" + + $"{entry.EndDate:dd.MM.} {entry.EndTime:HH\\:mm}"; + } + + return string.Join(" · ", new[] { time, entry.CalendarGroup, entry.Location } + .Where(part => !string.IsNullOrWhiteSpace(part))); + } } public class LessonItem @@ -783,12 +840,14 @@ public partial class CalendarDayCell : ObservableObject public bool HasLesson { get; } public bool HasExam { get; } public bool HasSession { get; } + public bool HasAnnualPlanEvent { get; } public bool IsOwnClassDay { get; } public string Tooltip { get; } public IReadOnlyList Events { get; } internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday, - bool hasLesson, bool hasExam, bool hasSession, bool isOwnClassDay, List details) + bool hasLesson, bool hasExam, bool hasSession, bool hasAnnualPlanEvent, + bool isOwnClassDay, List details) { Date = date; DayNumber = date.Day; @@ -797,6 +856,7 @@ public partial class CalendarDayCell : ObservableObject HasLesson = hasLesson; HasExam = hasExam; HasSession = hasSession; + HasAnnualPlanEvent = hasAnnualPlanEvent; IsOwnClassDay = isOwnClassDay; Events = details; Tooltip = details.Count == 0 ? date.ToString("dd.MM.yyyy") @@ -804,20 +864,22 @@ public partial class CalendarDayCell : ObservableObject } } -public enum CalendarEventKind { Lesson, Exam, ParticipationSession } +public enum CalendarEventKind { Lesson, Exam, ParticipationSession, AnnualPlan } public sealed class CalendarEventItem(CalendarEventKind kind, DateOnly date, string title, - string subtitle, Guid groupId) + string subtitle, Guid? groupId, string description = "") { public CalendarEventKind Kind { get; } = kind; public DateOnly Date { get; } = date; public string Title { get; } = title; public string Subtitle { get; } = subtitle; - public Guid GroupId { get; } = groupId; + public Guid? GroupId { get; } = groupId; + public string Description { get; } = description; public string KindLabel => Kind switch { CalendarEventKind.Exam => "Klausur", CalendarEventKind.ParticipationSession => "Sitzung", + CalendarEventKind.AnnualPlan => "Jahresplan", _ => "Unterricht" }; } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index 87ce4f7..0645af9 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -202,6 +202,14 @@ public partial class SettingsViewModel : ObservableObject [ObservableProperty] private bool _untisFetchBusy; public Func? OnReviewUntisMapping { get; set; } + // ── Schulweiter Jahresplan (ClassyPlan-iCal) ───────────────────────────── + + [ObservableProperty] private bool _annualPlanIsConfigured; + [ObservableProperty] private string _annualPlanIcalUrlInput = ""; + [ObservableProperty] private string _annualPlanUrlError = ""; + [ObservableProperty] private string _annualPlanStatusDisplay = ""; + [ObservableProperty] private bool _annualPlanFetchBusy; + // ── Synchronisation (Kapitel 10) ────────────────────────────────────────── [ObservableProperty] private string _syncServerUrl = ""; @@ -281,6 +289,8 @@ public partial class SettingsViewModel : ObservableObject private readonly AiPlanningService _aiPlanning; private readonly WebUntisSettingsService _untisSettings; private readonly UntisSyncService? _untisSync; + private readonly AnnualPlanSettingsService _annualPlanSettings; + private readonly AnnualPlanSyncService? _annualPlanSync; private readonly SyncSettingsService _syncSettings; private readonly SyncAuthService _syncAuth; private readonly EventQueue _eventQueue; @@ -303,11 +313,12 @@ public partial class SettingsViewModel : ObservableObject ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, AiSettingsService aiSettings, AiPlanningService aiPlanning, WebUntisSettingsService untisSettings, + AnnualPlanSettingsService annualPlanSettings, SyncSettingsService syncSettings, SyncAuthService syncAuth, EventQueue eventQueue, AppLogger logger, SyncKeyStatus syncKeyStatus, SyncKeyRecoveryService syncKeyRecovery, AppearanceSettingsService appearance, TrashViewModel trashTab, SnapshotService? snapshotService = null, SyncEngine? syncEngine = null, - UntisSyncService? untisSync = null) + UntisSyncService? untisSync = null, AnnualPlanSyncService? annualPlanSync = null) { _logger = logger; _syncKeyRecovery = syncKeyRecovery; @@ -337,6 +348,8 @@ public partial class SettingsViewModel : ObservableObject _aiPlanning = aiPlanning; _untisSettings = untisSettings; _untisSync = untisSync; + _annualPlanSettings = annualPlanSettings; + _annualPlanSync = annualPlanSync; _syncSettings = syncSettings; _syncAuth = syncAuth; _eventQueue = eventQueue; @@ -360,6 +373,7 @@ public partial class SettingsViewModel : ObservableObject LoadLetterTemplates(); LoadAiSettings(); LoadUntisSettings(); + LoadAnnualPlanSettings(); LoadSyncSettings(); LoadSyncConflicts(); } @@ -557,6 +571,68 @@ public partial class SettingsViewModel : ObservableObject if (OnReviewUntisMapping is not null) await OnReviewUntisMapping(); } + // ── Schulweiter Jahresplan: Laden / Speichern / Entfernen / Jetzt abrufen ─ + + private void LoadAnnualPlanSettings() + { + AnnualPlanIsConfigured = _annualPlanSettings.IsConfigured; + AnnualPlanStatusDisplay = _annualPlanSettings.LastSyncAt is { } at + ? $"Letzter Abgleich: {at.ToLocalTime():dd.MM.yyyy HH:mm} — {_annualPlanSettings.LastSyncStatus}" + : "Noch kein Abgleich durchgeführt."; + } + + [RelayCommand] + private void AnnualPlanSaveUrl() + { + AnnualPlanUrlError = ""; + if (string.IsNullOrWhiteSpace(AnnualPlanIcalUrlInput)) + { + AnnualPlanUrlError = "iCal-URL erforderlich."; + return; + } + if (!Uri.TryCreate(AnnualPlanIcalUrlInput, UriKind.Absolute, out var uri) || + uri.Scheme is not ("http" or "https")) + { + AnnualPlanUrlError = "Ungültige HTTP(S)-URL."; + return; + } + + _annualPlanSettings.SetIcalUrl(AnnualPlanIcalUrlInput.Trim()); + _annualPlanSettings.SetEnabled(true); + AnnualPlanIcalUrlInput = ""; + AppBootstrapper.RestartApplication(); + } + + [RelayCommand] + private void AnnualPlanRemove() + { + _annualPlanSync?.Clear(); + _annualPlanSettings.ClearIcalUrl(); + LoadAnnualPlanSettings(); + AppBootstrapper.RestartApplication(); + } + + [RelayCommand] + private async Task AnnualPlanFetchNow() + { + if (_annualPlanSync is null) + { + AnnualPlanStatusDisplay = "Abgleich nicht aktiv — App neu starten."; + return; + } + + AnnualPlanFetchBusy = true; + try + { + await _annualPlanSync.PollAsync(); + LoadAnnualPlanSettings(); + } + finally + { + AnnualPlanFetchBusy = false; + } + } + // ── Synchronisation: Laden / Anmelden / Abmelden / Verbindungstest ─────── // // Server-URL, Zugangsdaten und Token werden erst nach erfolgreichem Login zusammen diff --git a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml index ba1819e..c56c025 100644 --- a/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml +++ b/LehrerApp.Desktop/Views/Dashboard/DashboardView.axaml @@ -214,6 +214,7 @@ HorizontalAlignment="Center" VerticalAlignment="Bottom"> + @@ -222,25 +223,29 @@ - - + + - + - + - + + + + + - + @@ -256,6 +261,9 @@ + diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index b296fc4..ef23130 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -921,10 +921,14 @@ - - + + - + + + + + + + + + + + + + + + +