This commit is contained in:
@@ -93,6 +93,29 @@ public sealed class CreateLetterDialogViewModelTests : IDisposable
|
||||
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) =>
|
||||
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<LetterGroupChoice> Groups { get; } = [];
|
||||
public ObservableCollection<LetterGenerationIssue> Issues { get; } = [];
|
||||
public ObservableCollection<LetterPlaceholderInput> CustomPlaceholders { get; } = [];
|
||||
public bool HasIssues => Issues.Count > 0;
|
||||
public bool HasCustomPlaceholders => CustomPlaceholders.Count > 0;
|
||||
public bool HasNoTemplates => Templates.Count == 0;
|
||||
public bool HasNoContacts => Contacts.Count == 0;
|
||||
public bool UsesAttendanceAdvancedContent => UsesAttendanceCalendar || UsesAbsenceDayList;
|
||||
@@ -68,6 +70,7 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(UsesAttendanceAdvancedContent));
|
||||
AttendanceCalendarConfigured = false;
|
||||
ResetAttendanceCalendarOptions();
|
||||
RebuildCustomPlaceholders(value);
|
||||
RefreshValidation();
|
||||
}
|
||||
partial void OnSelectedContactChanged(LetterContactChoice? value) => RefreshValidation();
|
||||
@@ -117,11 +120,49 @@ public partial class CreateLetterDialogViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasIssues));
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues() => LetterPlaceholderBuilder.BuildStandardValues(
|
||||
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
||||
UsesAttendanceCalendar ? _attendanceCalendarFactory?.Invoke(_attendanceCalendarOptions) : null,
|
||||
UsesAbsenceDayList ? _absenceDayListFactory?.Invoke(_attendanceCalendarOptions) : null);
|
||||
private IReadOnlyDictionary<string, PlaceholderValue> BuildValues()
|
||||
{
|
||||
var values = LetterPlaceholderBuilder.BuildStandardValues(
|
||||
_student, SelectedContact?.Model, SelectedGroup?.Model,
|
||||
DateOnly.FromDateTime((LetterDate ?? DateTimeOffset.Now).LocalDateTime), LetterText, TeacherName,
|
||||
UsesAttendanceCalendar ? _attendanceCalendarFactory?.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;
|
||||
|
||||
@@ -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 LetterGenerationIssue(string message, bool isStrong)
|
||||
{ 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"/>
|
||||
</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"
|
||||
IsVisible="{Binding HasIssues}">
|
||||
<StackPanel Spacing="5">
|
||||
|
||||
@@ -49,6 +49,7 @@ public partial class DesignerViewModel : ObservableObject
|
||||
[ObservableProperty] private string _selectedPageTemplate = "first";
|
||||
[ObservableProperty] private DesignerPreviewPage? _selectedPreviewPage;
|
||||
[ObservableProperty] private SpecialContentItem? _selectedSpecialContent;
|
||||
[ObservableProperty] private bool _isLegacyContinuationSupported = true;
|
||||
|
||||
public IReadOnlyList<string> Units { get; } = ["mm", "cm", "pt", "in"];
|
||||
public IReadOnlyList<PlaceholderType> PlaceholderTypes { get; } = Enum.GetValues<PlaceholderType>();
|
||||
@@ -586,6 +587,8 @@ public partial class DesignerViewModel : ObservableObject
|
||||
try
|
||||
{
|
||||
var page = new LayoutParser().Parse(value);
|
||||
IsLegacyContinuationSupported = !page.UsesPageTemplates;
|
||||
if (page.UsesPageTemplates) UseContinuationLayout = false;
|
||||
OverlayPageWidth = page.Width; OverlayPageHeight = page.Height; OverlayPageUnit = page.Unit;
|
||||
var names = page.PageTemplates.Select(x => x.Name).ToList();
|
||||
if (names.Count == 0) names.Add("legacy");
|
||||
|
||||
@@ -271,12 +271,12 @@
|
||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/>
|
||||
<Button Grid.Column="3" Content="Als PDF exportieren …" Click="OnExportCurrentPdf"/></Grid>
|
||||
<TabControl Grid.Row="1" Margin="12">
|
||||
<TabItem Header="Seite 1">
|
||||
<TabItem Header="Skript">
|
||||
<TextBox Text="{Binding LayoutSource}" AcceptsReturn="True" TextWrapping="NoWrap"
|
||||
FontFamily="Menlo,Consolas,monospace" FontSize="13" VerticalContentAlignment="Top"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||
</TabItem>
|
||||
<TabItem Header="Folgeseiten">
|
||||
<TabItem Header="Folgeseiten (Legacy)" IsVisible="{Binding IsLegacyContinuationSupported}">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
<CheckBox Margin="8" Content="Eigenes Layout für Seite 2 und alle weiteren Seiten im Paket speichern"
|
||||
IsChecked="{Binding UseContinuationLayout}"/>
|
||||
|
||||
@@ -108,7 +108,7 @@ public sealed class PdfImportPipeline
|
||||
var blank = templatePath is null ? null : Extract(templatePath);
|
||||
EnsureCompatible(example, blank);
|
||||
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);
|
||||
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 > 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 assets = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -134,6 +134,8 @@ public sealed class PdfImportPipeline
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"PAGE {N(first.Width)} {N(first.Height)} pt",
|
||||
"#pragma format-version 3",
|
||||
"#pragma page-template first",
|
||||
"BG pdf-import-background.png",
|
||||
};
|
||||
var definitions = new List<PlaceholderDefinition>();
|
||||
@@ -157,6 +159,7 @@ public sealed class PdfImportPipeline
|
||||
definitions.Add(new(name, multiline ? PlaceholderType.Multiline : candidate.Type, false));
|
||||
candidate.Name = name;
|
||||
}
|
||||
lines.Add("#pragma end-page-template");
|
||||
var manifest = new TemplateManifest
|
||||
{
|
||||
Id = "pdf-import", Name = "PDF-Import", Description = "Automatisch aus einem PDF rekonstruiert",
|
||||
|
||||
Reference in New Issue
Block a user