This commit is contained in:
@@ -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<string> Tags,
|
||||
IReadOnlyList<TemplateValidationIssue> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verwaltet DOCX-Briefvorlagen und befüllt Word-Inhaltssteuerelemente anhand ihres Tags.
|
||||
/// Die Originalvorlage wird nie verändert.
|
||||
/// </summary>
|
||||
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<LetterPlaceholder> 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<LetterTemplateInfo> 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<string>();
|
||||
var issues = new List<TemplateValidationIssue>();
|
||||
|
||||
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<string, string?> 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<ZipArchiveEntry> 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<LetterTemplateInfo> LoadIndex()
|
||||
{
|
||||
if (!File.Exists(_indexPath)) return [];
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<LetterTemplateInfo>>(File.ReadAllText(_indexPath), JsonOptions) ?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveIndex(List<LetterTemplateInfo> templates)
|
||||
{
|
||||
var temporaryPath = _indexPath + ".tmp";
|
||||
File.WriteAllText(temporaryPath, JsonSerializer.Serialize(templates, JsonOptions));
|
||||
File.Move(temporaryPath, _indexPath, overwrite: true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user