317 lines
13 KiB
C#
317 lines
13 KiB
C#
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);
|
|
}
|
|
}
|