diff --git a/.vscode/launch.json b/.vscode/launch.json index 0e12390..44ede54 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,7 @@ "name": "Desktop App starten", "type": "coreclr", "request": "launch", - "preLaunchTask": "Desktop bauen", + "preLaunchTask": "TemplateDesigner bauen", "program": "${workspaceFolder}/LehrerApp.Desktop/bin/Debug/net10.0/LehrerApp.Desktop.dll", "args": [], "cwd": "${workspaceFolder}/LehrerApp.Desktop", @@ -15,6 +15,20 @@ "DOTNET_ENVIRONMENT": "Development" } }, + { + "name": "TemplateDesigner App starten", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "Desktop bauen", + "program": "${workspaceFolder}/LehrerApp.TemplateDesigner/bin/Debug/net10.0/LehrerApp.TemplateDesigner.dll", + "args": [], + "cwd": "${workspaceFolder}/LehrerApp.TemplateDesigner", + "stopAtEntry": false, + "console": "internalConsole", + "env": { + "DOTNET_ENVIRONMENT": "Development" + } + }, { "name": "API Server starten", "type": "coreclr", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 21aeba4..3aeed1a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -27,6 +27,19 @@ "problemMatcher": "$msCompile", "group": "build" }, + { + "label": "TemplateDesigner bauen", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/LehrerApp.TemplateDesigner/LehrerApp.TemplateDesigner.csproj", + "--configuration", "Debug", + "--verbosity", "minimal" + ], + "problemMatcher": "$msCompile", + "group": "build" + }, { "label": "Solution bauen", "command": "dotnet", diff --git a/Directory.Packages.props b/Directory.Packages.props index 0545be3..31bc9c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,9 +17,9 @@ - + + diff --git a/LehrerApp.Core/Services/LetterTemplateService.cs b/LehrerApp.Core/Services/LetterTemplateService.cs deleted file mode 100644 index 32c7fa3..0000000 --- a/LehrerApp.Core/Services/LetterTemplateService.cs +++ /dev/null @@ -1,316 +0,0 @@ -using System.IO.Compression; -using System.Text.Json; -using System.Xml.Linq; - -namespace LehrerApp.Core.Services; - -public enum TemplateIssueSeverity -{ - Warning, - StrongWarning, - Error, -} - -public sealed record LetterPlaceholder(string Tag, string Description); - -public sealed record TemplateValidationIssue( - TemplateIssueSeverity Severity, - string Message, - string? Tag = null, - string? SuggestedTag = null); - -public sealed record TemplateValidationResult( - IReadOnlyList Tags, - IReadOnlyList Issues) -{ - public bool CanGenerate => Issues.All(i => i.Severity != TemplateIssueSeverity.Error); - public bool HasStrongWarnings => Issues.Any(i => i.Severity == TemplateIssueSeverity.StrongWarning); -} - -public sealed class LetterTemplateInfo -{ - public Guid Id { get; set; } = Guid.NewGuid(); - public string Name { get; set; } = ""; - public string StoredFileName { get; set; } = ""; - public string OriginalFileName { get; set; } = ""; - public DateTime ImportedAt { get; set; } = DateTime.UtcNow; -} - -/// -/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags. -/// Die Originalvorlage wird nie verändert. -/// -public sealed class LetterTemplateService -{ - private static readonly XNamespace W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; - - public static IReadOnlyList SupportedPlaceholders { get; } = - [ - new("Student.FirstName", "Vorname des Schülers"), - new("Student.LastName", "Nachname des Schülers"), - new("Contact.Name", "Name des ausgewählten Kontakts"), - new("Contact.Address", "Vollständige Anschrift (mehrzeilig)"), - new("Contact.Street", "Straße und Hausnummer"), - new("Contact.PostalCode", "Postleitzahl"), - new("Contact.City", "Ort"), - new("Letter.Salutation", "Gespeicherte Briefanrede des Kontakts"), - new("Group.Name", "Ausgewählte Lerngruppe"), - new("SchoolYear", "Schuljahr der ausgewählten Gruppe"), - new("CurrentDate", "Gewähltes Briefdatum"), - ]; - - private readonly string _templateDirectory; - private readonly string _indexPath; - - public LetterTemplateService(string appDataPath) - { - _templateDirectory = Path.Combine(appDataPath, "letter-templates"); - _indexPath = Path.Combine(_templateDirectory, "templates.json"); - Directory.CreateDirectory(_templateDirectory); - } - - public IReadOnlyList GetTemplates() => LoadIndex() - .OrderBy(t => t.Name, StringComparer.CurrentCultureIgnoreCase) - .ToList(); - - public string GetTemplatePath(LetterTemplateInfo template) => - Path.Combine(_templateDirectory, template.StoredFileName); - - public LetterTemplateInfo Import(string sourcePath, string? displayName = null) - { - var validation = Validate(sourcePath); - if (!validation.CanGenerate) - throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message); - - var templates = LoadIndex(); - var info = new LetterTemplateInfo - { - Name = string.IsNullOrWhiteSpace(displayName) - ? Path.GetFileNameWithoutExtension(sourcePath) - : displayName.Trim(), - OriginalFileName = Path.GetFileName(sourcePath), - }; - info.StoredFileName = $"{info.Id:N}.docx"; - File.Copy(sourcePath, GetTemplatePath(info), overwrite: false); - templates.Add(info); - SaveIndex(templates); - return info; - } - - public void Delete(Guid id) - { - var templates = LoadIndex(); - var template = templates.FirstOrDefault(t => t.Id == id); - if (template is null) return; - var path = GetTemplatePath(template); - if (File.Exists(path)) File.Delete(path); - templates.Remove(template); - SaveIndex(templates); - } - - public TemplateValidationResult Validate(LetterTemplateInfo template) => - Validate(GetTemplatePath(template)); - - public TemplateValidationResult Validate(string path) - { - var tags = new List(); - var issues = new List(); - - if (!File.Exists(path)) - return Error("Die Vorlagendatei wurde nicht gefunden."); - if (!string.Equals(Path.GetExtension(path), ".docx", StringComparison.OrdinalIgnoreCase)) - return Error("Die Vorlage muss eine DOCX-Datei sein."); - - try - { - using var archive = ZipFile.OpenRead(path); - var xmlEntries = WordXmlEntries(archive).ToList(); - if (xmlEntries.All(e => !string.Equals(e.FullName, "word/document.xml", StringComparison.OrdinalIgnoreCase))) - return Error("Die Datei ist kein gültiges Word-DOCX-Dokument."); - - foreach (var entry in xmlEntries) - { - using var stream = entry.Open(); - var document = XDocument.Load(stream, LoadOptions.PreserveWhitespace); - foreach (var control in document.Descendants(W + "sdt")) - { - var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim(); - if (string.IsNullOrWhiteSpace(tag)) - { - issues.Add(new(TemplateIssueSeverity.StrongWarning, - $"Ein Inhaltssteuerelement in {PartLabel(entry.FullName)} besitzt keinen Tag und kann nicht befüllt werden.")); - continue; - } - tags.Add(tag); - } - } - } - catch (InvalidDataException) - { - return Error("Die Datei ist beschädigt oder kein gültiges DOCX-Dokument."); - } - catch (System.Xml.XmlException) - { - return Error("Die Word-Vorlage enthält ungültiges XML und kann nicht gelesen werden."); - } - catch (IOException ex) - { - return Error($"Die Vorlage konnte nicht geöffnet werden: {ex.Message}"); - } - - if (tags.Count == 0 && issues.Count == 0) - issues.Add(new(TemplateIssueSeverity.Warning, - "Die Vorlage enthält keine Inhaltssteuerelemente. Es werden keine Felder befüllt.")); - - foreach (var tag in tags.Distinct(StringComparer.Ordinal)) - { - if (SupportedPlaceholders.Any(p => p.Tag == tag)) continue; - var suggestion = FindSuggestion(tag); - issues.Add(suggestion is null - ? new(TemplateIssueSeverity.Warning, - $"Der unbekannte Tag „{tag}“ wird nicht befüllt.", tag) - : new(TemplateIssueSeverity.StrongWarning, - $"Wahrscheinlicher Schreibfehler: „{tag}“. Meinten Sie „{suggestion}“?", tag, suggestion)); - } - - return new(tags.Distinct(StringComparer.Ordinal).OrderBy(t => t).ToList(), issues); - - TemplateValidationResult Error(string message) => - new([], [new(TemplateIssueSeverity.Error, message)]); - } - - public void Generate(string templatePath, string outputPath, - IReadOnlyDictionary values) - { - var validation = Validate(templatePath); - if (!validation.CanGenerate) - throw new InvalidDataException(validation.Issues.First(i => i.Severity == TemplateIssueSeverity.Error).Message); - - var outputDirectory = Path.GetDirectoryName(outputPath); - if (!string.IsNullOrEmpty(outputDirectory)) Directory.CreateDirectory(outputDirectory); - - using var source = ZipFile.OpenRead(templatePath); - using var target = ZipFile.Open(outputPath, ZipArchiveMode.Create); - foreach (var entry in source.Entries) - { - var targetEntry = target.CreateEntry(entry.FullName, CompressionLevel.Optimal); - targetEntry.LastWriteTime = entry.LastWriteTime; - using var input = entry.Open(); - using var output = targetEntry.Open(); - - if (!IsWordXmlEntry(entry)) - { - input.CopyTo(output); - continue; - } - - var document = XDocument.Load(input, LoadOptions.PreserveWhitespace); - foreach (var control in document.Descendants(W + "sdt").ToList()) - { - var tag = control.Element(W + "sdtPr")?.Element(W + "tag")?.Attribute(W + "val")?.Value?.Trim(); - if (tag is null || !values.TryGetValue(tag, out var value)) continue; - ReplaceContent(control.Element(W + "sdtContent"), value ?? ""); - } - document.Save(output, SaveOptions.DisableFormatting); - } - } - - private static void ReplaceContent(XElement? content, string value) - { - if (content is null) return; - var paragraph = content.Elements(W + "p").FirstOrDefault(); - XElement run; - - if (paragraph is not null) - { - run = paragraph.Descendants(W + "r").FirstOrDefault() ?? new XElement(W + "r"); - var paragraphProperties = paragraph.Element(W + "pPr"); - var runProperties = run.Element(W + "rPr") is { } rp ? new XElement(rp) : null; - paragraph.RemoveNodes(); - if (paragraphProperties is not null) paragraph.Add(paragraphProperties); - run = new XElement(W + "r"); - if (runProperties is not null) run.Add(runProperties); - paragraph.Add(run); - foreach (var extra in content.Elements(W + "p").Skip(1).ToList()) extra.Remove(); - foreach (var node in content.Nodes().Where(n => n is XElement e && e.Name != W + "p").ToList()) node.Remove(); - } - else - { - var firstRun = content.Descendants(W + "r").FirstOrDefault(); - var runProperties = firstRun?.Element(W + "rPr") is { } rp ? new XElement(rp) : null; - content.RemoveNodes(); - run = new XElement(W + "r"); - if (runProperties is not null) run.Add(runProperties); - content.Add(run); - } - - var lines = value.Replace("\r\n", "\n", StringComparison.Ordinal).Replace('\r', '\n').Split('\n'); - for (var i = 0; i < lines.Length; i++) - { - if (i > 0) run.Add(new XElement(W + "br")); - run.Add(new XElement(W + "t", new XAttribute(XNamespace.Xml + "space", "preserve"), lines[i])); - } - } - - private static IEnumerable WordXmlEntries(ZipArchive archive) => - archive.Entries.Where(IsWordXmlEntry); - - private static bool IsWordXmlEntry(ZipArchiveEntry entry) => - entry.FullName.StartsWith("word/", StringComparison.OrdinalIgnoreCase) - && entry.FullName.EndsWith(".xml", StringComparison.OrdinalIgnoreCase); - - private static string PartLabel(string path) => path switch - { - "word/document.xml" => "Dokumenttext", - var p when p.StartsWith("word/header", StringComparison.OrdinalIgnoreCase) => "einer Kopfzeile", - var p when p.StartsWith("word/footer", StringComparison.OrdinalIgnoreCase) => "einer Fußzeile", - _ => $"dem Dokumentteil „{Path.GetFileName(path)}“", - }; - - private static string? FindSuggestion(string unknown) - { - var candidate = SupportedPlaceholders - .Select(p => (p.Tag, Distance: Levenshtein(unknown.ToLowerInvariant(), p.Tag.ToLowerInvariant()))) - .OrderBy(x => x.Distance) - .ThenBy(x => x.Tag) - .First(); - var threshold = candidate.Tag.Length >= 12 ? 3 : 2; - return candidate.Distance <= threshold ? candidate.Tag : null; - } - - private static int Levenshtein(string left, string right) - { - var previous = Enumerable.Range(0, right.Length + 1).ToArray(); - for (var i = 1; i <= left.Length; i++) - { - var current = new int[right.Length + 1]; - current[0] = i; - for (var j = 1; j <= right.Length; j++) - current[j] = Math.Min(Math.Min(current[j - 1] + 1, previous[j] + 1), - previous[j - 1] + (left[i - 1] == right[j - 1] ? 0 : 1)); - previous = current; - } - return previous[right.Length]; - } - - private List LoadIndex() - { - if (!File.Exists(_indexPath)) return []; - try - { - return JsonSerializer.Deserialize>(File.ReadAllText(_indexPath), JsonOptions) ?? []; - } - catch (JsonException) - { - return []; - } - } - - private void SaveIndex(List templates) - { - var temporaryPath = _indexPath + ".tmp"; - File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions)); - File.Move(temporaryPath, _indexPath, overwrite: true); - } -} diff --git a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs index 7c633a0..391d7ee 100644 --- a/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/CreateLetterDialogViewModelTests.cs @@ -1,7 +1,6 @@ -using System.IO.Compression; using LehrerApp.Core.Models; -using LehrerApp.Core.Services; using LehrerApp.Desktop.ViewModels.Students; +using LehrerApp.Templating; using Xunit; namespace LehrerApp.Desktop.Tests; @@ -9,16 +8,12 @@ namespace LehrerApp.Desktop.Tests; public sealed class CreateLetterDialogViewModelTests : IDisposable { private readonly string _directory = Path.Combine(Path.GetTempPath(), $"lehrerapp-letter-vm-tests-{Guid.NewGuid():N}"); - public CreateLetterDialogViewModelTests() => Directory.CreateDirectory(_directory); [Fact] public void OhneVorlage_KannKeinenBriefErzeugen() { - var student = StudentWithContact("Sehr geehrte Frau Muster,"); - - var vm = Build(student, new LetterTemplateService(_directory)); - + var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), new TemplateStore(_directory)); Assert.False(vm.CanGenerate); Assert.Contains(vm.Issues, i => i.Message.Contains("Vorlage", StringComparison.OrdinalIgnoreCase)); } @@ -26,83 +21,50 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable [Fact] public void VerwendeteBriefanredeFehlt_BlockiertErzeugung() { - var service = ServiceWithTemplate("Letter.Salutation"); - var vm = Build(StudentWithContact(null), service); - + var vm = Build(StudentWithContact(null), StoreWithTemplate( + new PlaceholderDefinition("Anrede", PlaceholderType.Text, true))); Assert.False(vm.CanGenerate); - Assert.Contains(vm.Issues, i => i.Message.Contains("Briefanrede")); + Assert.Contains(vm.Issues, i => i.Message.Contains("Anrede", StringComparison.OrdinalIgnoreCase)); } [Fact] - public void VollstaendigeDaten_ErzeugenEditierbareDocxKopie() + public void VollstaendigeDaten_ErzeugenFlachesPdf() { - var service = ServiceWithTemplate("Letter.Salutation", "Student.FirstName", "CurrentDate"); - var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), service); - var output = Path.Combine(_directory, "Brief.docx"); + var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true), + new PlaceholderDefinition("Datum", PlaceholderType.Date, true), + new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true)); + var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store); + vm.LetterText = "Dies ist der Inhalt."; + var output = Path.Combine(_directory, "Brief.pdf"); - var generated = vm.Generate(output); - - Assert.True(generated); - Assert.True(File.Exists(output)); - using var archive = ZipFile.OpenRead(output); - using var reader = new StreamReader(archive.GetEntry("word/document.xml")!.Open()); - var xml = reader.ReadToEnd(); - Assert.Contains("Sehr geehrte Frau Muster,", xml); - Assert.Contains("Lena", xml); + Assert.True(vm.Generate(output)); + Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4)); } - [Fact] - public void KontaktBearbeiten_SpeichertExpliziteBriefanrede() + private CreateLetterDialogViewModel Build(Student student, TemplateStore store) => + new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([])); + + private TemplateStore StoreWithTemplate(params PlaceholderDefinition[] definitions) { - var vm = new ContactEditDialogViewModel(new Contact { Name = "Frau Muster" }); - vm.LetterSalutation = "Sehr geehrte Frau Muster,"; - - vm.SaveCommand.Execute(null); - - Assert.Equal("Sehr geehrte Frau Muster,", vm.Result!.LetterSalutation); - } - - private CreateLetterDialogViewModel Build(Student student, LetterTemplateService service) => - new(student, service, new FakeMemberships([]), new FakeGroups([])); - - private LetterTemplateService ServiceWithTemplate(params string[] tags) - { - var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.docx"); - using (var archive = ZipFile.Open(source, ZipArchiveMode.Create)) + var source = Path.Combine(_directory, $"{Guid.NewGuid():N}.lavorlage"); + var manifest = new TemplateManifest { Id = $"brief-{Guid.NewGuid():N}", Name = "Elternbrief", Placeholders = [.. definitions] }; + var lines = new List { "PAGE 210 297 mm" }; + var y = 20; + foreach (var definition in definitions) { - var entry = archive.CreateEntry("word/document.xml"); - using var writer = new StreamWriter(entry.Open()); - var controls = string.Join("", tags.Select(tag => - $"" + - "Platzhalter")); - writer.Write("" + - $"{controls}"); + var element = definition.Type == PlaceholderType.Multiline ? "TEXTBOX" : "TEXT"; + lines.Add(element == "TEXTBOX" ? $"TEXTBOX 20 {y} 170 80 ${definition.Name}" : $"TEXT 20 {y} ${definition.Name}"); + y += 20; } - var service = new LetterTemplateService(Path.Combine(_directory, $"service-{Guid.NewGuid():N}")); - service.Import(source, "Elternbrief"); - return service; + TemplatePackage.Create(source, manifest, string.Join('\n', lines), new Dictionary()); + var store = new TemplateStore(Path.Combine(_directory, $"store-{Guid.NewGuid():N}")); store.Import(source); return store; } private static Student StudentWithContact(string? salutation) => new() { - FirstName = "Lena", - LastName = "Beispiel", - Contacts = - [ - new Contact - { - Name = "Frau Muster", - Relation = "Mutter", - LetterSalutation = salutation, - Street = "Hauptstraße 1", - PostalCode = "12345", - City = "Musterstadt", - }, - ], + FirstName = "Lena", LastName = "Beispiel", + Contacts = [new Contact { Name = "Frau Muster", Relation = "Mutter", LetterSalutation = salutation, + Street = "Hauptstraße 1", PostalCode = "12345", City = "Musterstadt" }], }; - - public void Dispose() - { - if (Directory.Exists(_directory)) Directory.Delete(_directory, recursive: true); - } + public void Dispose() { if (Directory.Exists(_directory)) Directory.Delete(_directory, true); } } diff --git a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs index 32d48d6..1241703 100644 --- a/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs +++ b/LehrerApp.Desktop.Tests/SettingsViewModelTests.cs @@ -7,6 +7,7 @@ using LehrerApp.Sync; using LehrerApp.Sync.Models; using System.Linq; using Xunit; +using LehrerApp.Templating; namespace LehrerApp.Desktop.Tests; @@ -34,7 +35,7 @@ public sealed class SettingsViewModelTests new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), holidays ?? new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), new PeriodScheduleService(tempPath), supervisionDuties ?? new FakeSupervisionDuties(), - new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), @@ -320,7 +321,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), calendarSettings, new PeriodScheduleService(tempPath), - new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), @@ -348,7 +349,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, - new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), @@ -380,7 +381,7 @@ public sealed class SettingsViewModelTests new LiteDbContext(new MemoryStream()), new PrivacySettingsService(tempPath), new FakeDocumentation(), new FakeStudents([]), new FakeShorthandCodes([]), new FakeSchoolHolidays(), new SchoolCalendarSettingsService(tempPath), periodSchedule, - new FakeSupervisionDuties(), new LetterTemplateService(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), + new FakeSupervisionDuties(), new TemplateStore(tempPath), TestSupport.BuildAiSettingsService(), TestSupport.BuildAiPlanningService(), TestSupport.BuildWebUntisSettingsService(), TestSupport.BuildAnnualPlanSettingsService(), TestSupport.BuildSyncSettingsService(), TestSupport.BuildSyncAuthService(), TestSupport.BuildEventQueue(), diff --git a/LehrerApp.Desktop/AppBootstrapper.cs b/LehrerApp.Desktop/AppBootstrapper.cs index ed874da..aa8d60a 100644 --- a/LehrerApp.Desktop/AppBootstrapper.cs +++ b/LehrerApp.Desktop/AppBootstrapper.cs @@ -16,6 +16,7 @@ using LehrerApp.Sync; using LehrerApp.Sync.Crypto; using LehrerApp.Sync.Models; using Microsoft.Extensions.DependencyInjection; +using LehrerApp.Templating; namespace LehrerApp.Desktop; @@ -117,6 +118,9 @@ public static class AppBootstrapper services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(_ => new TemplateStore(appData)); // ── Datensicherheit (13.3) ─────────────────────────────────────────── // Backup läuft vor dem Öffnen der Datenbank, rein dateibasiert (unabhängig von @@ -193,7 +197,6 @@ public static class AppBootstrapper services.AddSingleton(_ => new DashboardSettingsService(appData)); services.AddSingleton(_ => new WindowSettingsService(appData)); services.AddSingleton(_ => new AppearanceSettingsService(appData)); - services.AddSingleton(_ => new LetterTemplateService(appData)); services.AddSingleton(); // ── KI-Unterstützung (4.5.9, optional – nur wenn in den Einstellungen aktiviert) ────── diff --git a/LehrerApp.Desktop/LehrerApp.Desktop.csproj b/LehrerApp.Desktop/LehrerApp.Desktop.csproj index 9b9dc4c..1e080d4 100644 --- a/LehrerApp.Desktop/LehrerApp.Desktop.csproj +++ b/LehrerApp.Desktop/LehrerApp.Desktop.csproj @@ -10,6 +10,7 @@ + diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.LetterTemplates.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.LetterTemplates.cs index fa085d8..20454b4 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.LetterTemplates.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.LetterTemplates.cs @@ -1,37 +1,19 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; -using LehrerApp.Core.Interfaces; -using LehrerApp.Core.Models; -using LehrerApp.Core.Services; -using LehrerApp.Data; -using LehrerApp.Desktop.Services; -using LehrerApp.Desktop.ViewModels.Planning; -using LehrerApp.Sync; -using LehrerApp.Sync.Crypto; +using LehrerApp.Templating; using System.Collections.ObjectModel; -using System.Globalization; -using System.Security.Cryptography; -using System.Text.Json; -using System.Text.Json.Serialization; namespace LehrerApp.Desktop.ViewModels.Settings; public partial class SettingsViewModel { - // ── Word-Briefvorlagen (7.1.4 / 11.5) ─────────────────────────────────── - [ObservableProperty] private string _letterTemplateStatus = ""; public ObservableCollection LetterTemplateList { get; } = []; - public IReadOnlyList SupportedLetterPlaceholders => - LetterTemplateService.SupportedPlaceholders; - - // ── Word-Briefvorlagen: Import und Validierung ────────────────────────── private void LoadLetterTemplates() { LetterTemplateList.Clear(); - foreach (var template in _letterTemplates.GetTemplates()) - LetterTemplateList.Add(new LetterTemplateListItem(template, _letterTemplates.Validate(template))); + foreach (var template in _letterTemplates.GetTemplates()) LetterTemplateList.Add(CreateItem(template)); } public void ImportLetterTemplate(string path) @@ -40,75 +22,59 @@ public partial class SettingsViewModel try { var template = _letterTemplates.Import(path); - var validation = _letterTemplates.Validate(template); LoadLetterTemplates(); - LetterTemplateStatus = validation.Issues.Count == 0 - ? "Vorlage importiert und ohne Auffälligkeiten geprüft." - : $"Vorlage importiert. Die Prüfung meldet {validation.Issues.Count} Hinweis(e)."; - } - catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) - { - LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; + LetterTemplateStatus = $"„{template.Name}“ wurde geprüft und lokal importiert."; } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException) + { LetterTemplateStatus = $"Import fehlgeschlagen: {ex.Message}"; } } [RelayCommand] private void ValidateLetterTemplate(LetterTemplateListItem? item) { if (item is null) return; - var index = LetterTemplateList.IndexOf(item); - var refreshed = new LetterTemplateListItem(item.Model, _letterTemplates.Validate(item.Model)); - if (index >= 0) LetterTemplateList[index] = refreshed; - LetterTemplateStatus = refreshed.Validation.Issues.Count == 0 - ? $"„{item.Name}“ ist ohne Auffälligkeiten." - : $"„{item.Name}“: {refreshed.Validation.Issues.Count} Hinweis(e)."; + try + { + var refreshed = CreateItem(item.Model); + var index = LetterTemplateList.IndexOf(item); + if (index >= 0) LetterTemplateList[index] = refreshed; + LetterTemplateStatus = $"„{item.Name}“ ist gültig (Schema {refreshed.SchemaVersion})."; + } + catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) + { LetterTemplateStatus = $"„{item.Name}“ ist ungültig: {ex.Message}"; } } [RelayCommand] private void DeleteLetterTemplate(LetterTemplateListItem? item) { if (item is null) return; - _letterTemplates.Delete(item.Id); - LetterTemplateList.Remove(item); - LetterTemplateStatus = "Vorlage gelöscht."; + _letterTemplates.Delete(item.Id); LetterTemplateList.Remove(item); LetterTemplateStatus = "Vorlage gelöscht."; + } + + private LetterTemplateListItem CreateItem(InstalledTemplate template) + { + var loaded = _letterTemplates.Load(template); + return new(template, loaded.Manifest.SchemaVersion, loaded.Manifest.Placeholders.Count, + loaded.Manifest.Placeholders.Count(x => x.Required)); } } -public sealed class LetterTemplateListItem +public sealed class LetterTemplateListItem(InstalledTemplate model, int schemaVersion, int fieldCount, int requiredCount) { - public LetterTemplateInfo Model { get; } - public TemplateValidationResult Validation { get; } - public Guid Id => Model.Id; + public InstalledTemplate Model { get; } = model; + public string Id => Model.Id; public string Name => Model.Name; - public string OriginalFileName => Model.OriginalFileName; - public bool HasIssues => Validation.Issues.Count > 0; - public bool HasNoIssues => !HasIssues; - public string ValidationSummary => HasNoIssues - ? $"{Validation.Tags.Count} Feld(er) · keine Auffälligkeiten" - : $"{Validation.Tags.Count} Feld(er) · {Validation.Issues.Count} Hinweis(e)"; - public ObservableCollection Issues { get; } - - public LetterTemplateListItem(LetterTemplateInfo model, TemplateValidationResult validation) - { - Model = model; - Validation = validation; - Issues = new(validation.Issues.Select(i => new LetterTemplateIssueItem(i))); - } + public string PackageFileName => Path.GetFileName(Model.PackagePath); + public int SchemaVersion { get; } = schemaVersion; + public bool HasIssues => false; + public bool HasNoIssues => true; + public string ValidationSummary => $"Schema {SchemaVersion} · {fieldCount} Felder ({requiredCount} Pflicht)"; + public ObservableCollection Issues { get; } = []; } -public sealed class LetterTemplateIssueItem(TemplateValidationIssue issue) +public sealed class LetterTemplateIssueItem(ValidationIssue issue) { - public string Icon => issue.Severity switch - { - TemplateIssueSeverity.Error => "⛔", - TemplateIssueSeverity.StrongWarning => "⚠", - _ => "ⓘ", - }; + public string Icon => issue.Severity == ValidationSeverity.Error ? "⛔" : "ⓘ"; public string Message => issue.Message; - public string Color => issue.Severity switch - { - TemplateIssueSeverity.Error => "#DC2626", - TemplateIssueSeverity.StrongWarning => "#D97706", - _ => "#6B7280", - }; + public string Color => issue.Severity == ValidationSeverity.Error ? "#DC2626" : "#6B7280"; } diff --git a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs index b6fd28e..a55bea3 100644 --- a/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Settings/SettingsViewModel.cs @@ -13,6 +13,7 @@ using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; +using LehrerApp.Templating; namespace LehrerApp.Desktop.ViewModels.Settings; @@ -59,7 +60,7 @@ public partial class SettingsViewModel : ObservableObject private readonly IDocumentationRepository _documentation; private readonly IStudentRepository _students; private readonly IShorthandCodeRepository _shorthandCodes; - private readonly LetterTemplateService _letterTemplates; + private readonly TemplateStore _letterTemplates; [ObservableProperty] private int _activeTabIndex; @@ -97,7 +98,7 @@ public partial class SettingsViewModel : ObservableObject IDocumentationRepository documentation, IStudentRepository students, IShorthandCodeRepository shorthandCodes, ISchoolHolidayRepository schoolHolidays, SchoolCalendarSettingsService calendarSettings, PeriodScheduleService periodSchedule, - ISupervisionDutyRepository supervisionDuties, LetterTemplateService letterTemplates, + ISupervisionDutyRepository supervisionDuties, TemplateStore letterTemplates, AiSettingsService aiSettings, AiPlanningService aiPlanning, WebUntisSettingsService untisSettings, AnnualPlanSettingsService annualPlanSettings, diff --git a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs index 263b746..560c709 100644 --- a/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs +++ b/LehrerApp.Desktop/ViewModels/Students/CreateLetterDialogViewModel.cs @@ -1,21 +1,23 @@ using CommunityToolkit.Mvvm.ComponentModel; using LehrerApp.Core.Interfaces; using LehrerApp.Core.Models; -using LehrerApp.Core.Services; +using LehrerApp.Templating; using System.Collections.ObjectModel; -using System.Globalization; namespace LehrerApp.Desktop.ViewModels.Students; public partial class CreateLetterDialogViewModel : ObservableObject { private readonly Student _student; - private readonly LetterTemplateService _templates; + private readonly TemplateStore _templates; + private readonly ITemplateRenderer _renderer; [ObservableProperty] private LetterTemplateChoice? _selectedTemplate; [ObservableProperty] private LetterContactChoice? _selectedContact; [ObservableProperty] private LetterGroupChoice? _selectedGroup; [ObservableProperty] private DateTimeOffset? _letterDate = DateTimeOffset.Now; + [ObservableProperty] private string _letterText = ""; + [ObservableProperty] private string _teacherName = ""; [ObservableProperty] private string _generationError = ""; [ObservableProperty] private bool _canGenerate; @@ -28,155 +30,92 @@ public partial class CreateLetterDialogViewModel : ObservableObject public bool HasNoTemplates => Templates.Count == 0; public bool HasNoContacts => Contacts.Count == 0; public string SuggestedFileName => SanitizeFileName( - $"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.docx"); + $"{SelectedTemplate?.Name ?? "Elternbrief"}_{_student.LastName}_{_student.FirstName}.pdf"); - public CreateLetterDialogViewModel(Student student, LetterTemplateService templates, + public CreateLetterDialogViewModel(Student student, TemplateStore templates, ITemplateRenderer renderer, IGroupMembershipRepository memberships, IGroupRepository groups) { - _student = student; - _templates = templates; - - foreach (var template in templates.GetTemplates()) - Templates.Add(new LetterTemplateChoice(template)); - foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) - Contacts.Add(new LetterContactChoice(contact)); + _student = student; _templates = templates; _renderer = renderer; + foreach (var template in templates.GetTemplates()) Templates.Add(new(template)); + foreach (var contact in student.Contacts.Where(c => !c.InvalidSince.HasValue).OrderBy(c => c.Name)) Contacts.Add(new(contact)); foreach (var membership in memberships.GetByStudent(student.Id)) - { - var group = groups.GetById(membership.GroupId); - if (group is not null) Groups.Add(new LetterGroupChoice(group)); - } - - SelectedTemplate = Templates.FirstOrDefault(); - SelectedContact = Contacts.FirstOrDefault(); - SelectedGroup = Groups.FirstOrDefault(); + if (groups.GetById(membership.GroupId) is { } group) Groups.Add(new(group)); + SelectedTemplate = Templates.FirstOrDefault(); SelectedContact = Contacts.FirstOrDefault(); SelectedGroup = Groups.FirstOrDefault(); RefreshValidation(); } - partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) - { - OnPropertyChanged(nameof(SuggestedFileName)); - RefreshValidation(); - } + partial void OnSelectedTemplateChanged(LetterTemplateChoice? value) { OnPropertyChanged(nameof(SuggestedFileName)); RefreshValidation(); } partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); partial void OnSelectedGroupChanged(LetterGroupChoice? value) => RefreshValidation(); partial void OnLetterDateChanged(DateTimeOffset? value) => RefreshValidation(); + partial void OnLetterTextChanged(string value) => RefreshValidation(); + partial void OnTeacherNameChanged(string value) => RefreshValidation(); public bool Generate(string outputPath) { - RefreshValidation(); - if (!CanGenerate || SelectedTemplate is null) return false; - GenerationError = ""; + RefreshValidation(); if (!CanGenerate || SelectedTemplate is null) return false; try { - _templates.Generate(_templates.GetTemplatePath(SelectedTemplate.Model), outputPath, BuildValues()); - return true; - } - catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException) - { - GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}"; - return false; + var pdf = _renderer.RenderToPdf(_templates.Load(SelectedTemplate.Model), new LetterDataProvider(BuildValues())); + var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); + File.WriteAllBytes(outputPath, pdf); GenerationError = ""; return true; } + catch (Exception ex) when (ex is IOException or InvalidDataException or UnauthorizedAccessException or TemplateValidationException) + { GenerationError = $"Der Brief konnte nicht erzeugt werden: {ex.Message}"; return false; } } private void RefreshValidation() { - Issues.Clear(); - GenerationError = ""; - - if (SelectedTemplate is null) - Issues.Add(new("Bitte zuerst in den Einstellungen eine DOCX-Briefvorlage importieren.", true)); - if (SelectedContact is null) - Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true)); - if (LetterDate is null) - Issues.Add(new("Bitte ein Briefdatum auswählen.", true)); - + Issues.Clear(); GenerationError = ""; + if (SelectedTemplate is null) Issues.Add(new("Bitte zuerst in den Einstellungen ein .lavorlage-Paket importieren.", true)); + if (SelectedContact is null) Issues.Add(new("Für den Schüler ist kein aktueller Kontakt ausgewählt.", true)); + if (LetterDate is null) Issues.Add(new("Bitte ein Briefdatum auswählen.", true)); if (SelectedTemplate is not null) { - var validation = _templates.Validate(SelectedTemplate.Model); - foreach (var issue in validation.Issues) - Issues.Add(new(issue.Message, issue.Severity is TemplateIssueSeverity.StrongWarning or TemplateIssueSeverity.Error)); - - var tags = validation.Tags.ToHashSet(StringComparer.Ordinal); - var values = BuildValues(); - foreach (var tag in tags.Where(t => values.TryGetValue(t, out var value) && string.IsNullOrWhiteSpace(value))) + try { - var message = tag switch - { - "Letter.Salutation" => "Beim ausgewählten Kontakt fehlt die Briefanrede.", - "Contact.Address" or "Contact.Street" or "Contact.PostalCode" or "Contact.City" => - $"Beim ausgewählten Kontakt fehlt der Wert für „{tag}“.", - "Group.Name" or "SchoolYear" => "Die Vorlage verwendet Gruppendaten; bitte eine Lerngruppe auswählen.", - _ => $"Für das Vorlagenfeld „{tag}“ ist kein Wert vorhanden.", - }; - Issues.Add(new(message, true)); + var loaded = _templates.Load(SelectedTemplate.Model); var values = BuildValues(); + var validation = new TemplateLoader().Validate(loaded, values.ToDictionary(x => x.Key, x => x.Value.Type)); + foreach (var issue in validation.Issues) Issues.Add(new(issue.Message, issue.Severity == ValidationSeverity.Error)); + foreach (var required in loaded.Manifest.Placeholders.Where(x => x.Required && values.TryGetValue(x.Name, out var value) && IsEmpty(value))) + Issues.Add(new($"Für das Pflichtfeld „{required.Name}“ ist kein Wert vorhanden.", true)); } + catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) + { Issues.Add(new($"Vorlage ist ungültig: {ex.Message}", true)); } } - - CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null - && Issues.Count == 0; + CanGenerate = SelectedTemplate is not null && SelectedContact is not null && LetterDate is not null && Issues.Count == 0; OnPropertyChanged(nameof(HasIssues)); } - private IReadOnlyDictionary BuildValues() + private IReadOnlyDictionary BuildValues() { - var contact = SelectedContact?.Model; - var group = SelectedGroup?.Model; - var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City } - .Where(v => !string.IsNullOrWhiteSpace(v))); - var address = string.Join(Environment.NewLine, new[] { contact?.Street, cityLine } - .Where(v => !string.IsNullOrWhiteSpace(v))); - var date = LetterDate is null ? null : DateOnly.FromDateTime(LetterDate.Value.LocalDateTime) - .ToString("dd.MM.yyyy", CultureInfo.GetCultureInfo("de-DE")); - - return new Dictionary + var contact = SelectedContact?.Model; var group = SelectedGroup?.Model; + var cityLine = string.Join(" ", new[] { contact?.PostalCode, contact?.City }.Where(x => !string.IsNullOrWhiteSpace(x))); + var address = string.Join(Environment.NewLine, new[] { contact?.Name, contact?.Street, cityLine }.Where(x => !string.IsNullOrWhiteSpace(x))); + var date = DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime); + return new Dictionary(StringComparer.Ordinal) { - ["Student.FirstName"] = _student.FirstName, - ["Student.LastName"] = _student.LastName, - ["Contact.Name"] = contact?.Name, - ["Contact.Address"] = address, - ["Contact.Street"] = contact?.Street, - ["Contact.PostalCode"] = contact?.PostalCode, - ["Contact.City"] = contact?.City, - ["Letter.Salutation"] = contact?.LetterSalutation, - ["Group.Name"] = group?.Name, - ["SchoolYear"] = group?.SchoolYear, - ["CurrentDate"] = date, + ["Datum"] = new DateValue(date), ["CurrentDate"] = new DateValue(date), + ["Empfaenger"] = new TextValue(contact?.Name ?? ""), ["Anrede"] = new TextValue(contact?.LetterSalutation ?? ""), + ["Brieftext"] = new MultilineValue(LetterText), ["LehrerName"] = new TextValue(TeacherName), + ["Student.FirstName"] = new TextValue(_student.FirstName), ["Student.LastName"] = new TextValue(_student.LastName), + ["Contact.Name"] = new TextValue(contact?.Name ?? ""), ["Contact.Address"] = new MultilineValue(address), + ["Contact.Street"] = new TextValue(contact?.Street ?? ""), ["Contact.PostalCode"] = new TextValue(contact?.PostalCode ?? ""), + ["Contact.City"] = new TextValue(contact?.City ?? ""), ["Letter.Salutation"] = new TextValue(contact?.LetterSalutation ?? ""), + ["Group.Name"] = new TextValue(group?.Name ?? ""), ["SchoolYear"] = new TextValue(group?.SchoolYear ?? ""), }; } + private static bool IsEmpty(PlaceholderValue value) => value switch + { TextValue x => string.IsNullOrWhiteSpace(x.Value), MultilineValue x => string.IsNullOrWhiteSpace(x.Value), _ => false }; private static string SanitizeFileName(string value) - { - foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); - return value; - } -} - -public sealed class LetterTemplateChoice -{ - public LetterTemplateInfo Model { get; } - public string Name => Model.Name; - public LetterTemplateChoice(LetterTemplateInfo model) => Model = model; -} - -public sealed class LetterContactChoice -{ - public Contact Model { get; } - public string Display => string.IsNullOrWhiteSpace(Model.Relation) - ? Model.Name - : $"{Model.Name} · {Model.Relation}"; - public LetterContactChoice(Contact model) => Model = model; -} - -public sealed class LetterGroupChoice -{ - public LearningGroup Model { get; } - public string Display => $"{Model.Name} · {Model.SchoolYear}"; - public LetterGroupChoice(LearningGroup model) => Model = model; + { foreach (var character in Path.GetInvalidFileNameChars()) value = value.Replace(character, '_'); return value; } } +internal sealed class LetterDataProvider(IReadOnlyDictionary values) : ITemplateDataProvider +{ public IReadOnlyDictionary GetValues() => values; } +public sealed class LetterTemplateChoice(InstalledTemplate model) { public InstalledTemplate Model { get; } = model; public string Name => Model.Name; } +public sealed class LetterContactChoice(Contact model) { public Contact Model { get; } = model; public string Display => string.IsNullOrWhiteSpace(Model.Relation) ? Model.Name : $"{Model.Name} · {Model.Relation}"; } +public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; } public sealed class LetterGenerationIssue(string message, bool isStrong) -{ - public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; - public string Message { get; } = message; - public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; -} +{ public string Icon { get; } = isStrong ? "⚠" : "ⓘ"; public string Message { get; } = message; public string Color { get; } = isStrong ? "#D97706" : "#6B7280"; } diff --git a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml index 88ae2e3..5960ae9 100644 --- a/LehrerApp.Desktop/Views/Settings/SettingsView.axaml +++ b/LehrerApp.Desktop/Views/Settings/SettingsView.axaml @@ -328,15 +328,15 @@ - + - -