QuestPDF-Vorlagensystem und Designer hinzufügen
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-08-30 15:37:22 +02:00
parent a088823b30
commit 2f2761caf8
40 changed files with 2177 additions and 738 deletions
@@ -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<LetterTemplateListItem> LetterTemplateList { get; } = [];
public IReadOnlyList<LetterPlaceholder> 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<LetterTemplateIssueItem> 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<LetterTemplateIssueItem> 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";
}
@@ -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,
@@ -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<string, string?> BuildValues()
private IReadOnlyDictionary<string, PlaceholderValue> 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<string, string?>
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<string, PlaceholderValue>(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<string, PlaceholderValue> values) : ITemplateDataProvider
{ public IReadOnlyDictionary<string, PlaceholderValue> 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"; }