using System.IO.Compression; using System.Text.Json; namespace LehrerApp.Templating; public static class TemplatePackage { public const string Extension = ".lavorlage"; public static void Create(string outputPath, TemplateManifest manifest, string layoutSource, IReadOnlyDictionary assets, string? continuationLayoutSource = null) { if (!TemplateLoader.IsSafeRelativePath(manifest.MetadataFile)) throw new InvalidDataException("metadataFile enthält einen unsicheren Pfad."); if (TemplateLoader.Normalize(manifest.MetadataFile).Equals( TemplateLoader.Normalize(manifest.LayoutFile), StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("metadataFile und layoutFile dürfen nicht identisch sein."); var reservedPaths = new HashSet(StringComparer.OrdinalIgnoreCase) { "manifest.json", TemplateLoader.Normalize(manifest.LayoutFile), TemplateLoader.Normalize(manifest.MetadataFile) }; if (manifest.ContinuationLayoutFile is { } continuationPath) { if (!TemplateLoader.IsSafeRelativePath(continuationPath)) throw new InvalidDataException("continuationLayoutFile enthält einen unsicheren Pfad."); reservedPaths.Add(TemplateLoader.Normalize(continuationPath)); if (continuationLayoutSource is null) throw new InvalidDataException("Das Folgeseiten-Layout fehlt."); } if (!string.Equals(Path.GetExtension(outputPath), Extension, StringComparison.OrdinalIgnoreCase)) outputPath += Extension; var directory = Path.GetDirectoryName(outputPath); if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory); var temporary = outputPath + ".tmp"; try { using (var archive = ZipFile.Open(temporary, ZipArchiveMode.Create)) { WriteText(archive, "manifest.json", JsonSerializer.Serialize(manifest, TemplateLoader.JsonOptions)); WriteText(archive, manifest.LayoutFile, layoutSource); if (manifest.ContinuationLayoutFile is { } continuationFile) WriteText(archive, continuationFile, continuationLayoutSource!); WriteText(archive, manifest.MetadataFile, TemplateMetadataText.Serialize(manifest.Metadata)); foreach (var asset in assets) { if (!TemplateLoader.IsSafeRelativePath(asset.Key)) throw new InvalidDataException($"Unsicherer Assetpfad „{asset.Key}“."); if (reservedPaths.Contains(TemplateLoader.Normalize(asset.Key))) throw new InvalidDataException($"Assetpfad „{asset.Key}“ ist für Paketdaten reserviert."); var entry = archive.CreateEntry(TemplateLoader.Normalize(asset.Key), CompressionLevel.Optimal); using var stream = entry.Open(); stream.Write(asset.Value); } } using (var verify = File.OpenRead(temporary)) new TemplateLoader().LoadFromPackage(verify, Path.GetFileName(outputPath)); File.Move(temporary, outputPath, overwrite: true); } finally { if (File.Exists(temporary)) File.Delete(temporary); } } private static void WriteText(ZipArchive archive, string path, string content) { var entry = archive.CreateEntry(TemplateLoader.Normalize(path), CompressionLevel.Optimal); using var writer = new StreamWriter(entry.Open()); writer.Write(content); } } public sealed record InstalledTemplate(string Id, string Name, string Description, string PackagePath, DateTime ImportedAtUtc); public sealed class TemplateStore { private readonly string _directory; private readonly ITemplateLoader _loader; public TemplateStore(string appDataPath, ITemplateLoader? loader = null, string subfolder = "letter-template-packages") { _directory = Path.Combine(appDataPath, subfolder); Directory.CreateDirectory(_directory); _loader = loader ?? new TemplateLoader(); } public IReadOnlyList GetTemplates() { var result = new List(); foreach (var path in Directory.EnumerateFiles(_directory, $"*{TemplatePackage.Extension}")) { try { var loaded = _loader.LoadFromPackage(path); result.Add(new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, path, File.GetLastWriteTimeUtc(path))); } catch (Exception ex) when (ex is InvalidDataException or TemplateValidationException) { } } return result.OrderBy(x => x.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); } public InstalledTemplate Import(string sourcePath) { var loaded = _loader.LoadFromPackage(sourcePath); var safeId = string.Concat(loaded.Manifest.Id.Select(c => char.IsAsciiLetterOrDigit(c) || c == '-' ? c : '-')).Trim('-'); if (safeId.Length == 0) throw new InvalidDataException("Die Vorlagen-ID ist ungültig."); var destination = Path.Combine(_directory, safeId + TemplatePackage.Extension); var temporary = destination + ".tmp"; File.Copy(sourcePath, temporary, overwrite: true); File.Move(temporary, destination, overwrite: true); return new(loaded.Manifest.Id, loaded.Manifest.Name, loaded.Manifest.Description, destination, DateTime.UtcNow); } public LoadedTemplate Load(InstalledTemplate template) => _loader.LoadFromPackage(template.PackagePath); public void Delete(string id) { var match = GetTemplates().FirstOrDefault(x => x.Id == id); if (match is not null && File.Exists(match.PackagePath)) File.Delete(match.PackagePath); } } /// Getrennter Vorlagen-Speicher für Arbeitsblätter (eigener Unterordner) - reiner /// Marker-Typ, damit DI die Elternbrief- und die Arbeitsblatt-Vorlagenbibliothek als zwei /// unabhängige -Instanzen registrieren kann. public sealed class WorksheetTemplateStore(TemplateStore store) { public TemplateStore Store { get; } = store; }