Jahresplanimport und Ablgeich von Untis
This commit is contained in:
@@ -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<AnnualPlanEvent> GetAll();
|
||||
List<AnnualPlanEvent> GetByRange(DateOnly from, DateOnly to);
|
||||
AnnualPlanEvent? GetByExternalId(string externalId);
|
||||
void Save(AnnualPlanEvent entry);
|
||||
void Delete(Guid id);
|
||||
}
|
||||
public interface IDocumentationRepository
|
||||
{
|
||||
List<Documentation> GetByStudent(Guid studentId);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace LehrerApp.Core.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Ein aus dem schulweiten Jahresplan importierter Termin. Anders als <see cref="TimetableSlot"/>
|
||||
/// und <see cref="SubstitutionEntry"/> ist er reine Kalenderinformation: Er verändert weder den
|
||||
/// regulären Stundenplan noch erzeugt er Vertretungen oder Ausfälle.
|
||||
/// </summary>
|
||||
public class AnnualPlanEvent
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>Stabile UID aus dem iCal-Feed; fachlicher Schlüssel für idempotente Importe.</summary>
|
||||
public string ExternalId { get; set; } = "";
|
||||
|
||||
public string Title { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string Location { get; set; } = "";
|
||||
|
||||
/// <summary>Erster beziehungsweise letzter tatsächlich belegter lokaler Kalendertag.</summary>
|
||||
public DateOnly StartDate { get; set; }
|
||||
public DateOnly EndDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Bei Ganztagsterminen null. Zeitgebundene UTC-Werte aus dem Feed werden beim Import in die
|
||||
/// lokale Schulzeit Europe/Berlin umgerechnet.
|
||||
/// </summary>
|
||||
public TimeOnly? StartTime { get; set; }
|
||||
public TimeOnly? EndTime { get; set; }
|
||||
public bool IsAllDay { get; set; }
|
||||
|
||||
/// <summary>ClassyPlan-Gruppe, z.B. "Lehrkräfte" oder "Öffentlich".</summary>
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using LehrerApp.Core.Models;
|
||||
|
||||
namespace LehrerApp.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<AnnualPlanEvent> 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<AnnualPlanEvent>();
|
||||
Dictionary<string, Property>? 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<string, Property>(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<string, Property> 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<string, Property> properties, string key, int number) =>
|
||||
properties.TryGetValue(key, out var value) ? value : throw Invalid(number, $"{key} fehlt");
|
||||
|
||||
private static string Raw(Dictionary<string, Property> properties, string key) =>
|
||||
properties.TryGetValue(key, out var value) ? value.Value : "";
|
||||
|
||||
private static string Text(Dictionary<string, Property> 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<string> Unfold(string text)
|
||||
{
|
||||
var result = new List<string>();
|
||||
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}.");
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -68,6 +68,7 @@ public class LiteDbContext : IDisposable
|
||||
public ILiteCollection<SubstitutionEntry> SubstitutionEntries => _db.GetCollection<SubstitutionEntry>("substitution_entries");
|
||||
public ILiteCollection<UntisSnapshotEntry> UntisSnapshotEntries => _db.GetCollection<UntisSnapshotEntry>("untis_snapshot_entries");
|
||||
public ILiteCollection<UntisSlotMapping> UntisSlotMappings => _db.GetCollection<UntisSlotMapping>("untis_slot_mappings");
|
||||
public ILiteCollection<AnnualPlanEvent> AnnualPlanEvents => _db.GetCollection<AnnualPlanEvent>("annual_plan_events");
|
||||
public ILiteCollection<TrashedItem> TrashedItems => _db.GetCollection<TrashedItem>("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();
|
||||
|
||||
@@ -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<AnnualPlanEvent> GetAll() =>
|
||||
db.AnnualPlanEvents.FindAll().OrderBy(e => e.StartDate).ThenBy(e => e.StartTime).ToList();
|
||||
|
||||
public List<AnnualPlanEvent> 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<CompetencyDomain> GetBySubjectAndGrade(Guid subjectId, int gradeLevel) =>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<FormatException>(() => 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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 <see cref="BuildAiSettingsService"/>, 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<AnnualPlanEvent> _all = [];
|
||||
public void Add(AnnualPlanEvent e) => _all.Add(e);
|
||||
public List<AnnualPlanEvent> GetAll() => _all.ToList();
|
||||
public List<AnnualPlanEvent> 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<WorkTask> _all = [];
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -167,6 +167,7 @@ public static class AppBootstrapper
|
||||
services.AddSingleton<ISubstitutionEntryRepository, SubstitutionEntryRepository>();
|
||||
services.AddSingleton<IUntisSnapshotRepository, UntisSnapshotRepository>();
|
||||
services.AddSingleton<IUntisSlotMappingRepository, UntisSlotMappingRepository>();
|
||||
services.AddSingleton<IAnnualPlanEventRepository, AnnualPlanEventRepository>();
|
||||
|
||||
// ── Services ──────────────────────────────────────────────────────────
|
||||
services.AddSingleton<GradingService>();
|
||||
@@ -206,6 +207,17 @@ public static class AppBootstrapper
|
||||
sp.GetRequiredService<AppLogger>()));
|
||||
}
|
||||
|
||||
// ── 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<IAnnualPlanEventRepository>(),
|
||||
sp.GetRequiredService<AppLogger>()));
|
||||
}
|
||||
|
||||
// ── Sync (optional – nur wenn Server konfiguriert) ────────────────────
|
||||
var syncSettings = new SyncSettingsService(appData);
|
||||
services.AddSingleton(syncSettings);
|
||||
|
||||
@@ -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; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<string>(_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<AnnualPlanSettingsConfig>(File.ReadAllText(_configPath))
|
||||
?? new AnnualPlanSettingsConfig();
|
||||
}
|
||||
catch { /* beschädigte lokale Konfiguration -> Standardwerte */ }
|
||||
return new AnnualPlanSettingsConfig();
|
||||
}
|
||||
|
||||
private void Save() => File.WriteAllText(_configPath, JsonSerializer.Serialize(_config));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Periodischer Vollabgleich des ClassyPlan-Jahresplans. Dieser Dienst hat bewusst keinerlei
|
||||
/// Abhängigkeit zu Stundenplan-, Vertretungs- oder Lerngruppen-Repositories.
|
||||
/// </summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>HTTP-freier, vollständig testbarer Snapshot-Abgleich.</summary>
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -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<CalendarEventItem> 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<CalendarEventItem> Events { get; }
|
||||
|
||||
internal CalendarDayCell(DateOnly date, bool isCurrentMonth, bool isToday,
|
||||
bool hasLesson, bool hasExam, bool hasSession, bool isOwnClassDay, List<CalendarEventItem> details)
|
||||
bool hasLesson, bool hasExam, bool hasSession, bool hasAnnualPlanEvent,
|
||||
bool isOwnClassDay, List<CalendarEventItem> 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"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,6 +202,14 @@ public partial class SettingsViewModel : ObservableObject
|
||||
[ObservableProperty] private bool _untisFetchBusy;
|
||||
public Func<Task>? 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
|
||||
|
||||
@@ -214,6 +214,7 @@
|
||||
HorizontalAlignment="Center" VerticalAlignment="Bottom">
|
||||
<Ellipse Width="5" Height="5" Fill="#1E88E5" IsVisible="{Binding HasSession}"/>
|
||||
<Ellipse Width="5" Height="5" Fill="#E53935" IsVisible="{Binding HasExam}"/>
|
||||
<Ellipse Width="5" Height="5" Fill="#FF8A00" IsVisible="{Binding HasAnnualPlanEvent}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -222,25 +223,29 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="14" Margin="0,4,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<WrapPanel Orientation="Horizontal" Margin="0,4,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Border Width="10" Height="10" CornerRadius="3" Background="#14808080"/>
|
||||
<TextBlock Text="Unterricht" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Ellipse Width="7" Height="7" Fill="#E53935"/>
|
||||
<TextBlock Text="Klausur" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Ellipse Width="7" Height="7" Fill="#1E88E5"/>
|
||||
<TextBlock Text="Sitzung" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Ellipse Width="7" Height="7" Fill="#FF8A00"/>
|
||||
<TextBlock Text="Jahresplan" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,0,14,4">
|
||||
<Border Width="10" Height="10" CornerRadius="3" BorderThickness="2"
|
||||
BorderBrush="{DynamicResource SystemControlBackgroundAccentBrush}"/>
|
||||
<TextBlock Text="Meine Klasse" FontSize="10" Opacity="0.6"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
|
||||
<Separator Margin="0,4"/>
|
||||
<TextBlock Text="{Binding SelectedDayLabel}" FontWeight="SemiBold" FontSize="12"/>
|
||||
@@ -256,6 +261,9 @@
|
||||
<StackPanel Grid.Column="1">
|
||||
<TextBlock Text="{Binding Title}" FontSize="12" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Subtitle}" FontSize="10" Opacity="0.6"/>
|
||||
<TextBlock Text="{Binding Description}" FontSize="10" Opacity="0.6"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
@@ -921,10 +921,14 @@
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
<!-- Tab: WebUntis-iCal-Abgleich (Nutzer-Feedback) -->
|
||||
<ContentPage Header="Stundenplan-Abgleich">
|
||||
<!-- Gemeinsamer UI-Tab; beide Importpfade bleiben fachlich und technisch getrennt. -->
|
||||
<ContentPage Header="Untis-Einbettung">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="420">
|
||||
<StackPanel Margin="32,20,32,28" Spacing="14" MaxWidth="460">
|
||||
|
||||
<TextBlock Text="Untis-Einbettung" FontSize="18" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Bindet den persönlichen WebUntis-Stundenplan und den schulweiten Jahresplan als zwei unabhängige iCal-Quellen ein."/>
|
||||
|
||||
<TextBlock Text="WebUntis-Stundenplan-Abgleich" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
@@ -952,6 +956,36 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Separator Margin="0,8"/>
|
||||
|
||||
<TextBlock Text="Schulweiter Jahresplan" FontSize="16" FontWeight="SemiBold"/>
|
||||
<TextBlock FontSize="12" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="Importiert Konferenzen, Prüfungszeiträume, Fortbildungen und weitere schulweite Termine aus einem iCal-Feed. Diese Termine werden zusätzlich im Dashboard-Kalender angezeigt und verändern den persönlichen Stundenplan nicht."/>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding !AnnualPlanIsConfigured}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="iCal-URL" FontSize="12" Opacity="0.7"/>
|
||||
<TextBox Text="{Binding AnnualPlanIcalUrlInput}" PasswordChar="●"
|
||||
PlaceholderText="https://…/export.php?type=ics…"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding AnnualPlanUrlError}" Foreground="Red" FontSize="12"
|
||||
IsVisible="{Binding AnnualPlanUrlError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<Button Content="Speichern und aktivieren" Command="{Binding AnnualPlanSaveUrlCommand}"
|
||||
HorizontalAlignment="Left"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="8" IsVisible="{Binding AnnualPlanIsConfigured}">
|
||||
<TextBlock Text="Jahresplan-iCal hinterlegt (verschlüsselt gespeichert)."
|
||||
FontSize="13" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding AnnualPlanStatusDisplay}" FontSize="12" Opacity="0.7"
|
||||
TextWrapping="Wrap"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Jetzt abrufen" Command="{Binding AnnualPlanFetchNowCommand}"
|
||||
IsEnabled="{Binding !AnnualPlanFetchBusy}"/>
|
||||
<Button Content="Entfernen" Command="{Binding AnnualPlanRemoveCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</ContentPage>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using LehrerApp.Core.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace LehrerApp.Tests;
|
||||
|
||||
/// <summary>Synthetische Fixtures nach dem am 23.08.2026 geprüften ClassyPlan-Export.</summary>
|
||||
public sealed class AnnualPlanIcsParserTests
|
||||
{
|
||||
private static string Calendar(params string[] events) =>
|
||||
"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:ClassyPlan\n" +
|
||||
string.Join("", events) + "END:VCALENDAR\n";
|
||||
|
||||
[Fact]
|
||||
public void Parse_Ganztagstermin_NutztExklusivesDtendAlsInklusivesEnddatum()
|
||||
{
|
||||
var ics = Calendar(
|
||||
"BEGIN:VEVENT\nUID:100-ClassyPlan.app\n" +
|
||||
"DTSTART;VALUE=DATE:20260615\nDTEND;VALUE=DATE:20260620\n" +
|
||||
"SUMMARY:Klassenfahrt 6a 6b 6d\nX-GROUPNAME:Lehrkräfte\nX-COLOR:#4B9FD8\n" +
|
||||
"END:VEVENT\n");
|
||||
|
||||
var entry = Assert.Single(AnnualPlanIcsParser.Parse(ics));
|
||||
|
||||
Assert.True(entry.IsAllDay);
|
||||
Assert.Equal(new DateOnly(2026, 6, 15), entry.StartDate);
|
||||
Assert.Equal(new DateOnly(2026, 6, 19), entry.EndDate);
|
||||
Assert.Null(entry.StartTime);
|
||||
Assert.Equal("Lehrkräfte", entry.CalendarGroup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UtcTermin_WirdUnterBeruecksichtigungDerSommerzeitNachBerlinKonvertiert()
|
||||
{
|
||||
var ics = Calendar(
|
||||
"BEGIN:VEVENT\nUID:200-ClassyPlan.app\n" +
|
||||
"DTSTART:20260929T113000Z\nDTEND:20260929T130000Z\n" +
|
||||
"LAST-MODIFIED:20260814T095236Z\nSEQUENCE:2\nSTATUS:CONFIRMED\n" +
|
||||
"SUMMARY:Teams 5–10\nLOCATION:R 101\nX-GROUPNAME:Öffentlich\nEND:VEVENT\n");
|
||||
|
||||
var entry = Assert.Single(AnnualPlanIcsParser.Parse(ics));
|
||||
|
||||
Assert.False(entry.IsAllDay);
|
||||
Assert.Equal(new TimeOnly(13, 30), entry.StartTime);
|
||||
Assert.Equal(new TimeOnly(15, 0), entry.EndTime);
|
||||
Assert.Equal(2, entry.Sequence);
|
||||
Assert.Equal(new DateTime(2026, 8, 14, 9, 52, 36, DateTimeKind.Utc),
|
||||
entry.SourceLastModifiedUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EscapingUndZeilenfaltung_WerdenAufgeloest()
|
||||
{
|
||||
var ics = Calendar(
|
||||
"BEGIN:VEVENT\nUID:300-ClassyPlan.app\nDTSTART;VALUE=DATE:20260823\n" +
|
||||
"DESCRIPTION:Erste Zeile\\nZweite Zeile mit sehr langem \n Text\\, Komma\\; Semikolon\n" +
|
||||
"SUMMARY:Fortbildung\nEND:VEVENT\n");
|
||||
|
||||
var entry = Assert.Single(AnnualPlanIcsParser.Parse(ics));
|
||||
|
||||
Assert.Equal("Erste Zeile\nZweite Zeile mit sehr langem Text, Komma; Semikolon",
|
||||
entry.Description);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_EinDefektesVevent_LaesstNichtUnbemerktEinenTeilimportZu()
|
||||
{
|
||||
var ics = Calendar(
|
||||
"BEGIN:VEVENT\nUID:gueltig\nDTSTART;VALUE=DATE:20260823\nEND:VEVENT\n",
|
||||
"BEGIN:VEVENT\nUID:defekt\nSUMMARY:Ohne Datum\nEND:VEVENT\n");
|
||||
|
||||
var error = Assert.Throws<FormatException>(() => AnnualPlanIcsParser.Parse(ics));
|
||||
|
||||
Assert.Contains("DTSTART fehlt", error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_DoppelteUid_WirdAbgelehnt()
|
||||
{
|
||||
var ics = Calendar(
|
||||
"BEGIN:VEVENT\nUID:doppelt\nDTSTART;VALUE=DATE:20260823\nEND:VEVENT\n",
|
||||
"BEGIN:VEVENT\nUID:doppelt\nDTSTART;VALUE=DATE:20260824\nEND:VEVENT\n");
|
||||
|
||||
Assert.Throws<FormatException>(() => AnnualPlanIcsParser.Parse(ics));
|
||||
}
|
||||
}
|
||||
@@ -1160,6 +1160,38 @@ Lerngruppen, die aus mehreren Klassen zusammengesetzt sind." Zwei Ursachen, nach
|
||||
nur bei diesem konkreten Bugfix, sondern auch bei jeder künftigen Korrektur der Zuordnung durch
|
||||
den Nutzer selbst.
|
||||
|
||||
**Nachtrag zu 4.3, siebzehnte Iteration (schulweiter ClassyPlan-Jahresplan):** Zusätzlich zum
|
||||
persönlichen WebUntis-Stundenplan existiert ein schulweiter Jahresplan mit Konferenzen,
|
||||
Fortbildungen, Abschlussprüfungen, Fahrten und weiteren Terminen. Vor der Implementierung wurden
|
||||
CSV- und iCal-Export desselben Stands vollständig verglichen: jeweils 562 eindeutige Termine,
|
||||
keine fehlenden IDs und vollständige Übereinstimmung von Titel, Beschreibung, Ort und Zeit. iCal
|
||||
ist die Primärquelle, weil es darüber hinaus stabile UIDs, Änderungsmetadaten und für 264 in der
|
||||
CSV gruppenlose Zeilen die Information `X-GROUPNAME:Öffentlich` liefert. 186 der 562 Einträge sind
|
||||
echte DATE-Ganztags-/Mehrtagstermine.
|
||||
|
||||
- **Strikte fachliche Trennung:** `AnnualPlanEvent` ist reine Kalenderinformation und besitzt
|
||||
weder `SubstitutionKind` noch Stunden-/Pausennummer oder Lerngruppen-Mapping. Der neue
|
||||
`AnnualPlanSyncService` greift auf kein Stundenplan-/Vertretungsrepository zu. Erst die
|
||||
Anzeigeprojektion im Dashboard führt Jahresplan, Unterricht, Klausuren und Sitzungen zusammen.
|
||||
- **Eigener Parser:** `AnnualPlanIcsParser` unterstützt DATE-Werte mit exklusivem RFC-5545-DTEND,
|
||||
mehrtägige Termine, UTC→Europe/Berlin, Escaping und Line-Folding. Anders als der fail-soft
|
||||
WebUntis-Parser bricht er bei jedem ungültigen VEVENT den gesamten Import ab; dadurch kann ein
|
||||
Teilabruf niemals den übrigen lokalen Jahresplan als "verschwunden" löschen.
|
||||
- **Lokaler Vollabgleich:** `AnnualPlanEventRepository` wird idempotent über iCal-UID aktualisiert;
|
||||
erst nach erfolgreichem Vollparse werden nicht mehr enthaltene Termine entfernt. Wie die
|
||||
Untis-Snapshots feuert der wiederherstellbare externe Cache bewusst kein `db.OnChange` und
|
||||
erzeugt damit keine hunderte App-Sync-Ereignisse je Abruf. Automatischer Abruf alle sechs
|
||||
Stunden plus initialer Abruf beim Aufbau des Dashboards.
|
||||
- **Einstellungen/Anzeige:** Der gemeinsame Tab "Untis-Einbettung" bündelt zwei klar getrennte
|
||||
Bereiche für persönlichen Stundenplan-Abgleich und schulweiten Jahresplan, damit die
|
||||
Einstellungsnavigation kompakt bleibt. Der Jahresplanbereich bietet verschlüsselt gespeicherte
|
||||
iCal-URL, Status, manuellen Abruf und Entfernen inklusive Cache-Bereinigung. Im Dashboard orange
|
||||
markiert; Mehrtagstermine erscheinen an jedem betroffenen Tag, Detailzeilen zeigen Uhrzeit,
|
||||
Gruppe, Ort und Beschreibung. Jahresplantermine laufen parallel zum Unterricht und ersetzen
|
||||
ihn nie.
|
||||
- Tests decken Parservarianten, strikten Teilimport-Schutz, Snapshot-Idempotenz/-Bereinigung,
|
||||
verschlüsselte Einstellungen, Bereichsabfragen und die parallele Dashboard-Anzeige ab.
|
||||
|
||||
### 4.4 Wochen-/Tagesansicht
|
||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
||||
|
||||
Reference in New Issue
Block a user