using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; using System.Text.Json; using System.Text.Json.Serialization; namespace LehrerApp.Desktop.Services; /// /// Portables, menschen- und KI-lesbares Austauschformat für Unterrichtseinheiten und /// Einzelstunden. Interne Datenbank-IDs werden nie übertragen; jeder Import legt neue Objekte an. /// public sealed class PlanningExchangeService(IUnitRepository units, ILessonRepository lessons, IAlternativeLessonPathRepository alternativePaths) { public const string UnitSchema = "lehrerapp.unit-planning"; public const string LessonSchema = "lehrerapp.lesson-planning"; public const int CurrentVersion = 1; private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip, Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, }; public string ExportUnit(Unit unit, PlanningExchangeContext context) { var document = new UnitPlanningDocument { Schema = UnitSchema, Version = CurrentVersion, Context = context, Unit = ToPayload(unit, lessons.GetByUnit(unit.Id)), }; return JsonSerializer.Serialize(document, JsonOptions); } public string ExportLesson(Lesson lesson, PlanningExchangeContext context) { var document = new LessonPlanningDocument { Schema = LessonSchema, Version = CurrentVersion, Context = context, Lesson = ToPayload(lesson), }; return JsonSerializer.Serialize(document, JsonOptions); } public UnitImportResult ImportUnit(string json, Guid targetGroupId) { var document = Deserialize(json, UnitSchema); ValidateUnit(document.Unit); foreach (var lesson in document.Unit.Lessons) ValidateLesson(lesson); var unit = new Unit { GroupId = targetGroupId, Title = document.Unit.Title.Trim(), StartDate = document.Unit.StartDate, EndDate = document.Unit.EndDate, Competencies = document.Unit.Competencies .Where(c => !string.IsNullOrWhiteSpace(c)).Select(c => c.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase).ToList(), Status = document.Unit.Status, Notes = Clean(document.Unit.Notes), }; units.Save(unit); var importedLessons = new List(); foreach (var payload in document.Unit.Lessons) { var lesson = ToModel(payload, unit.Id, targetGroupId); lessons.Save(lesson); importedLessons.Add(lesson); } return new UnitImportResult(unit, importedLessons); } public Lesson ImportLesson(string json, Guid targetUnitId, Guid targetGroupId) { var document = Deserialize(json, LessonSchema); ValidateLesson(document.Lesson); var lesson = ToModel(document.Lesson, targetUnitId, targetGroupId); lessons.Save(lesson); return lesson; } private UnitPlanningPayload ToPayload(Unit unit, List unitLessons) => new() { Title = unit.Title, StartDate = unit.StartDate, EndDate = unit.EndDate, Competencies = [.. unit.Competencies], Status = unit.Status, Notes = unit.Notes, Lessons = [.. unitLessons.Select(ToPayload)], }; private LessonPlanningPayload ToPayload(Lesson lesson) => new() { Date = lesson.Date, LessonNumber = lesson.LessonNumber, Topic = lesson.Topic, StartTime = lesson.StartTime, Status = lesson.Status, Homework = lesson.Homework, Reflection = lesson.Reflection, Phases = [.. lesson.Phases.Select(p => new LessonPhasePayload { Name = p.Name, DurationMinutes = p.DurationMinutes, Activity = p.Activity, Material = p.Material, Shorthand = p.Shorthand, AlternativePath = p.AlternativePathId is Guid id ? alternativePaths.GetById(id)?.Name : null, })], }; private Lesson ToModel(LessonPlanningPayload payload, Guid unitId, Guid groupId) => new() { UnitId = unitId, GroupId = groupId, Date = payload.Date!.Value, LessonNumber = payload.LessonNumber, Topic = payload.Topic.Trim(), StartTime = payload.StartTime, Status = payload.Status, Homework = Clean(payload.Homework), Reflection = Clean(payload.Reflection), Phases = [.. payload.Phases.Select(p => new LessonPhaseStep { Name = p.Name?.Trim() ?? "", DurationMinutes = p.DurationMinutes, Activity = p.Activity?.Trim() ?? "", Material = p.Material?.Trim() ?? "", Shorthand = p.Shorthand?.Trim() ?? "", AlternativePathId = ResolveAlternativePath(p.AlternativePath), })], }; private Guid? ResolveAlternativePath(string? name) { name = Clean(name); if (name is null) return null; var existing = alternativePaths.GetAll().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); if (existing is not null) return existing.Id; var created = new AlternativeLessonPath { Name = name }; alternativePaths.Save(created); return created.Id; } private static T Deserialize(string json, string expectedSchema) where T : PlanningDocument { try { var document = JsonSerializer.Deserialize(RemoveMarkdownFence(json), JsonOptions) ?? throw new PlanningExchangeException("Die JSON-Datei ist leer."); if (!string.Equals(document.Schema, expectedSchema, StringComparison.Ordinal)) throw new PlanningExchangeException($"Falsches Format: Erwartet wird „{expectedSchema}“."); if (document.Version != CurrentVersion) throw new PlanningExchangeException($"Die Formatversion {document.Version} wird nicht unterstützt."); return document; } catch (JsonException ex) { throw new PlanningExchangeException($"Die JSON-Datei ist ungültig: {ex.Message}", ex); } } private static string RemoveMarkdownFence(string json) { var value = json.Trim(); if (!value.StartsWith("```", StringComparison.Ordinal)) return value; var firstLineEnd = value.IndexOf('\n'); if (firstLineEnd < 0) return value; value = value[(firstLineEnd + 1)..]; var closingFence = value.LastIndexOf("```", StringComparison.Ordinal); return closingFence >= 0 ? value[..closingFence].Trim() : value.Trim(); } private static void ValidateUnit(UnitPlanningPayload? unit) { if (unit is null) throw new PlanningExchangeException("Das Feld „unit“ fehlt."); if (string.IsNullOrWhiteSpace(unit.Title)) throw new PlanningExchangeException("Die Einheit benötigt einen Titel."); if (unit.StartDate is not null && unit.EndDate is not null && unit.EndDate < unit.StartDate) throw new PlanningExchangeException("Das Enddatum der Einheit liegt vor dem Startdatum."); unit.Competencies ??= []; unit.Lessons ??= []; } private static void ValidateLesson(LessonPlanningPayload? lesson) { if (lesson is null) throw new PlanningExchangeException("Das Feld „lesson“ fehlt."); if (lesson.Date is null) throw new PlanningExchangeException("Die Stunde benötigt ein Datum im Format JJJJ-MM-TT."); if (string.IsNullOrWhiteSpace(lesson.Topic)) throw new PlanningExchangeException("Die Stunde benötigt ein Thema."); if (lesson.LessonNumber is < 1 or > 20) throw new PlanningExchangeException("Die Stundennummer muss zwischen 1 und 20 liegen."); lesson.Phases ??= []; if (lesson.Phases.Any(p => p.DurationMinutes is < 0 or > 180)) throw new PlanningExchangeException("Die Dauer jeder Phase muss zwischen 0 und 180 Minuten liegen."); } private static string? Clean(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); } public abstract class PlanningDocument { public string Schema { get; set; } = ""; public int Version { get; set; } public PlanningExchangeContext? Context { get; set; } } public sealed class UnitPlanningDocument : PlanningDocument { public UnitPlanningPayload Unit { get; set; } = new(); } public sealed class LessonPlanningDocument : PlanningDocument { public LessonPlanningPayload Lesson { get; set; } = new(); } public sealed class PlanningExchangeContext { public string Group { get; set; } = ""; public string Subject { get; set; } = ""; public int GradeLevel { get; set; } public string? UnitTitle { get; set; } } public sealed class UnitPlanningPayload { public string Title { get; set; } = ""; public DateOnly? StartDate { get; set; } public DateOnly? EndDate { get; set; } public List Competencies { get; set; } = []; public UnitStatus Status { get; set; } = UnitStatus.Planned; public string? Notes { get; set; } public List Lessons { get; set; } = []; } public sealed class LessonPlanningPayload { public DateOnly? Date { get; set; } public int? LessonNumber { get; set; } public string Topic { get; set; } = ""; public TimeOnly? StartTime { get; set; } public LessonStatus Status { get; set; } = LessonStatus.Planned; public string? Homework { get; set; } public string? Reflection { get; set; } public List Phases { get; set; } = []; } public sealed class LessonPhasePayload { public string Name { get; set; } = ""; public int DurationMinutes { get; set; } public string Activity { get; set; } = ""; public string Material { get; set; } = ""; public string Shorthand { get; set; } = ""; public string? AlternativePath { get; set; } } public sealed record UnitImportResult(Unit Unit, List Lessons); public sealed class PlanningExchangeException : Exception { public PlanningExchangeException(string message) : base(message) { } public PlanningExchangeException(string message, Exception innerException) : base(message, innerException) { } }