This commit is contained in:
@@ -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");
|
||||||
|
|||||||
@@ -271,12 +271,12 @@
|
|||||||
<Button Grid.Column="1" Content="Prüfen & Vorschau" Click="OnPreview"/>
|
<Button Grid.Column="1" Content="Prüfen & 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",
|
||||||
|
|||||||
Reference in New Issue
Block a user