feat: add planning JSON exchange
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
using LehrerApp.Core.Interfaces;
|
||||
using LehrerApp.Core.Models;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace LehrerApp.Desktop.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Portables, menschen- und KI-lesbares Austauschformat für Unterrichtseinheiten und
|
||||
/// Einzelstunden. Interne Datenbank-IDs werden nie übertragen; jeder Import legt neue Objekte an.
|
||||
/// </summary>
|
||||
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<UnitPlanningDocument>(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<Lesson>();
|
||||
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<LessonPlanningDocument>(json, LessonSchema);
|
||||
ValidateLesson(document.Lesson);
|
||||
var lesson = ToModel(document.Lesson, targetUnitId, targetGroupId);
|
||||
lessons.Save(lesson);
|
||||
return lesson;
|
||||
}
|
||||
|
||||
private UnitPlanningPayload ToPayload(Unit unit, List<Lesson> 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<T>(string json, string expectedSchema) where T : PlanningDocument
|
||||
{
|
||||
try
|
||||
{
|
||||
var document = JsonSerializer.Deserialize<T>(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<string> Competencies { get; set; } = [];
|
||||
public UnitStatus Status { get; set; } = UnitStatus.Planned;
|
||||
public string? Notes { get; set; }
|
||||
public List<LessonPlanningPayload> 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<LessonPhasePayload> 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<Lesson> Lessons);
|
||||
|
||||
public sealed class PlanningExchangeException : Exception
|
||||
{
|
||||
public PlanningExchangeException(string message) : base(message) { }
|
||||
public PlanningExchangeException(string message, Exception innerException) : base(message, innerException) { }
|
||||
}
|
||||
Reference in New Issue
Block a user