feat: fehlende Variablen ergänzen
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-09-15 23:30:22 +02:00
parent 0b5cfc5522
commit 6e57bf407d
6 changed files with 129 additions and 9 deletions
@@ -93,6 +93,29 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
Assert.True(vm.CanGenerate); Assert.True(vm.CanGenerate);
} }
[Fact]
public void EigenerPlatzhalter_KannImDialogEingegebenWerden()
{
var store = StoreWithTemplate(new PlaceholderDefinition("Anrede", PlaceholderType.Text, true),
new PlaceholderDefinition("Datum", PlaceholderType.Date, true),
new PlaceholderDefinition("Brieftext", PlaceholderType.Multiline, true),
new PlaceholderDefinition("Betreff", PlaceholderType.Text, true));
var vm = Build(StudentWithContact("Sehr geehrte Frau Muster,"), store);
vm.LetterText = "Dies ist der Inhalt.";
var betreff = Assert.Single(vm.CustomPlaceholders);
Assert.Equal("Betreff", betreff.Name);
Assert.False(vm.CanGenerate);
Assert.Contains(vm.Issues, i => i.Message.Contains("Betreff", StringComparison.OrdinalIgnoreCase));
betreff.TextValue = "Wichtiger Termin";
var output = Path.Combine(_directory, "MitBetreff.pdf");
Assert.True(vm.CanGenerate);
Assert.True(vm.Generate(output));
Assert.Equal("%PDF", System.Text.Encoding.ASCII.GetString(File.ReadAllBytes(output), 0, 4));
}
private CreateLetterDialogViewModel Build(Student student, TemplateStore store) => private CreateLetterDialogViewModel Build(Student student, TemplateStore store) =>
new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([])); new(student, store, new QuestTemplateRenderer(), new FakeMemberships([]), new FakeGroups([]));
@@ -35,7 +35,9 @@ public partial class CreateLetterDialogViewModel : ObservableObject
public ObservableCollection<LetterContactChoice> Contacts { get; } = []; public ObservableCollection<LetterContactChoice> Contacts { get; } = [];
public ObservableCollection<LetterGroupChoice> Groups { get; } = []; public ObservableCollection<LetterGroupChoice> Groups { get; } = [];
public ObservableCollection<LetterGenerationIssue> Issues { get; } = []; public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
public bool HasIssues => Issues.Count > 0; public bool HasIssues => Issues.Count > 0;
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
public bool HasNoTemplates => Templates.Count == 0; public bool HasNoTemplates => Templates.Count == 0;
public bool HasNoContacts => Contacts.Count == 0; public bool HasNoContacts => Contacts.Count == 0;
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList; public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
@@ -68,6 +70,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent)); OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
AttendanceCalendarConfigured = false; AttendanceCalendarConfigured = false;
ResetAttendanceCalendarOptions(); ResetAttendanceCalendarOptions();
RebuildCustomPlaceholders(value);
RefreshValidation(); RefreshValidation();
} }
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation(); partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
@@ -117,11 +120,49 @@ public partial class CreateLetterDialogViewModel : ObservableObject
OnPropertyChanged(nameof(HasIssues)); OnPropertyChanged(nameof(HasIssues));
} }
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues( private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
{
var values = LetterPlaceholderBuilder.BuildStandardValues(
_student, SelectedContact?.Model, SelectedGroup?.Model, _student, SelectedContact?.Model, SelectedGroup?.Model,
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName, DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null, UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null); UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
foreach (var custom in CustomPlaceholders) values[custom.Name] = custom.ToPlaceholderValue();
return values;
}
private void RebuildCustomPlaceholders(LetterTemplateChoice? choice)
{
foreach (var existing in CustomPlaceholders) existing.PropertyChanged -= OnCustomPlaceholderChanged;
CustomPlaceholders.Clear();
if (choice is not null)
{
try
{
var loaded = _templates.Load(choice.Model);
foreach (var placeholder in loaded.Manifest.Placeholders.Where(p => !p.IsConstant
&& !StandardPlaceholderNames.Contains(p.Name) && p.Type is PlaceholderType.Text
or PlaceholderType.Multiline or PlaceholderType.Date or PlaceholderType.Number))
{
var input = new LetterPlaceholderInput(placeholder.Name, placeholder.Type);
input.PropertyChanged += OnCustomPlaceholderChanged;
CustomPlaceholders.Add(input);
}
}
catch (Exception ex) when (ex is IOException or InvalidDataException or TemplateValidationException) { }
}
OnPropertyChanged(nameof(HasCustomPlaceholders));
}
private void OnCustomPlaceholderChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) => RefreshValidation();
private static readonly HashSet<string> StandardPlaceholderNames = new(StringComparer.Ordinal)
{
"Datum", "CurrentDate", "Empfaenger", "Anrede", "Brieftext", "LehrerName",
"Student.FirstName", "Student.LastName", "Contact.Name", "Contact.Address", "Contact.Street",
"Contact.PostalCode", "Contact.City", "Letter.Salutation", "Group.Name", "SchoolYear",
StudentAttendanceCalendarDrawingBuilder.PlaceholderName, StudentAbsenceDayListDrawingBuilder.PlaceholderName,
};
public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions; public AttendanceCalendarOptions GetAttendanceCalendarOptions() => _attendanceCalendarOptions;
@@ -187,3 +228,33 @@ public sealed class LetterContactChoice(Contact model) { public Contact Model {
public sealed class LetterGroupChoice(LearningGroup model) { public LearningGroup Model { get; } = model; public string Display => $"{Model.Name} · {Model.SchoolYear}"; } 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 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"; }
public sealed partial class LetterPlaceholderInput : ObservableObject
{
public string Name { get; }
public PlaceholderType Type { get; }
public string Label => Type switch
{
PlaceholderType.Date => $"{Name} (Datum)",
PlaceholderType.Number => $"{Name} (Zahl)",
_ => Name,
};
public bool IsTextType => Type == PlaceholderType.Text;
public bool IsMultilineType => Type == PlaceholderType.Multiline;
public bool IsDateType => Type == PlaceholderType.Date;
public bool IsNumberType => Type == PlaceholderType.Number;
[ObservableProperty] private string _textValue = "";
[ObservableProperty] private DateTimeOffset? _dateValue;
[ObservableProperty] private decimal? _numberValue;
public LetterPlaceholderInput(string name, PlaceholderType type) { Name = name; Type = type; }
public PlaceholderValue ToPlaceholderValue() => Type switch
{
PlaceholderType.Multiline => new MultilineValue(TextValue),
PlaceholderType.Date => new DateValue(DateValue.HasValue ? DateOnly.FromDateTime(DateValue.Value.LocalDateTime) : default),
PlaceholderType.Number => new NumberValue(NumberValue ?? 0),
_ => new TextValue(TextValue),
};
}
@@ -65,6 +65,26 @@
<TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/> <TextBox Text="{Binding TeacherName}" PlaceholderText="Name der Lehrkraft"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="8" IsVisible="{Binding HasCustomPlaceholders}">
<TextBlock Text="Zusätzliche Felder der Vorlage" FontSize="12" Opacity="0.7"/>
<ItemsControl ItemsSource="{Binding CustomPlaceholders}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:LetterPlaceholderInput">
<StackPanel Spacing="4" Margin="0,0,0,8">
<TextBlock Text="{Binding Label}" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding TextValue}" IsVisible="{Binding IsTextType}"/>
<TextBox Text="{Binding TextValue}" AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"
IsVisible="{Binding IsMultilineType}"/>
<CalendarDatePicker SelectedDate="{Binding DateValue, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"
HorizontalAlignment="Stretch" IsVisible="{Binding IsDateType}"/>
<NumericUpDown Value="{Binding NumberValue}" FormatString="0.##" HorizontalAlignment="Stretch"
IsVisible="{Binding IsNumberType}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10" <Border BorderBrush="#D97706" BorderThickness="1" CornerRadius="6" Padding="10"
IsVisible="{Binding HasIssues}"> IsVisible="{Binding HasIssues}">
<StackPanel Spacing="5"> <StackPanel Spacing="5">
@@ -49,6 +49,7 @@ public partial class DesignerViewModel : ObservableObject
[ObservableProperty] private string _selectedPageTemplate = "first"; [ObservableProperty] private string _selectedPageTemplate = "first";
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage; [ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent; [ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
[ObservableProperty] private bool _isLegacyContinuationSupported = true;
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"]; public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>(); public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
@@ -586,6 +587,8 @@ public partial class DesignerViewModel : ObservableObject
try try
{ {
var page = new LayoutParser().Parse(value); var page = new LayoutParser().Parse(value);
IsLegacyContinuationSupported = !page.UsesPageTemplates;
if (page.UsesPageTemplates) UseContinuationLayout = false;
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit; OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
var names = page.PageTemplates.Select(x => x.Name).ToList(); var names = page.PageTemplates.Select(x => x.Name).ToList();
if (names.Count == 0) names.Add("legacy"); if (names.Count == 0) names.Add("legacy");
+2 -2
View File
@@ -271,12 +271,12 @@
<Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/> <Button Grid.Column="1" Content="Prüfen &amp; Vorschau" Click="OnPreview"/>
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid> <Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
<TabControl Grid.Row="1" Margin="12"> <TabControl Grid.Row="1" Margin="12">
<TabItem Header="Seite 1"> <TabItem Header="Skript">
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap" <TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top" FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/> ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
</TabItem> </TabItem>
<TabItem Header="Folgeseiten"> <TabItem Header="Folgeseiten (Legacy)" IsVisible="{Binding IsLegacyContinuationSupported}">
<Grid RowDefinitions="Auto,*"> <Grid RowDefinitions="Auto,*">
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern" <CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
IsChecked="{Binding UseContinuationLayout}"/> IsChecked="{Binding UseContinuationLayout}"/>
@@ -108,7 +108,7 @@ public sealed class PdfImportPipeline
var blank = templatePath is null ? null : Extract(templatePath); var blank = templatePath is null ? null : Extract(templatePath);
EnsureCompatible(example, blank); EnsureCompatible(example, blank);
if (example.Pages.Count > 1) if (example.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden."); throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
var candidates = FindCandidates(example, blank); var candidates = FindCandidates(example, blank);
if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password)) if (!string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{ {
@@ -124,7 +124,7 @@ public sealed class PdfImportPipeline
{ {
if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten."); if (document.Pages.Count == 0) throw new InvalidDataException("Das PDF enthält keine Seiten.");
if (document.Pages.Count > 1) if (document.Pages.Count > 1)
throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend als Folgeseitenlayout ergänzt werden."); throw new InvalidDataException("Der automatische Import unterstützt in v1 einseitige Vorlagen. Weitere PDF-Seiten können anschließend im Reiter „Seiten & Flows“ als zusätzliche Seitenvorlage (page-template continuation) ergänzt werden.");
var first = document.Pages[0]; var first = document.Pages[0];
var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase) var assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
{ {
@@ -134,6 +134,8 @@ public sealed class PdfImportPipeline
var lines = new List<string> var lines = new List<string>
{ {
$"PAGE {N(first.Width)} {N(first.Height)} pt", $"PAGE {N(first.Width)} {N(first.Height)} pt",
"#pragma format-version 3",
"#pragma page-template first",
"BG pdf-import-background.png", "BG pdf-import-background.png",
}; };
var definitions = new List<PlaceholderDefinition>(); var definitions = new List<PlaceholderDefinition>();
@@ -157,6 +159,7 @@ public sealed class PdfImportPipeline
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false)); definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
candidate.Name = name; candidate.Name = name;
} }
lines.Add("#pragma end-page-template");
var manifest = new TemplateManifest var manifest = new TemplateManifest
{ {
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert", Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",