using System.Collections.ObjectModel; namespace LehrerApp.Core.Importing; /// /// Stabile, typsichere Kennung eines Importformats. Werte werden normalisiert, damit /// Groß-/Kleinschreibung oder versehentliche Leerzeichen nicht zu Tippfehlern führen. /// public readonly record struct ImportFormatId { public string Value { get; } public ImportFormatId(string value) { ArgumentException.ThrowIfNullOrWhiteSpace(value); Value = value.Trim().ToLowerInvariant(); } public override string ToString() => Value ?? ""; } /// Eine vollständig in den Speicher geladene Importdatei. public sealed class ImportFile { public string FileName { get; } public ReadOnlyMemory Content { get; } public string Extension => Path.GetExtension(FileName).ToLowerInvariant(); public ImportFile(string fileName, ReadOnlyMemory content) { ArgumentException.ThrowIfNullOrWhiteSpace(fileName); FileName = Path.GetFileName(fileName); Content = content; } } public enum ImportMessageSeverity { Information, Warning, Error } public sealed record ImportMessage( ImportMessageSeverity Severity, string Code, string Message, string? SourceReference = null); /// /// Ergebnis einer schnellen Formaterkennung. 0 bedeutet „kein Treffer“, 100 einen /// eindeutigen Treffer. Die eigentliche vollständige Prüfung erfolgt erst beim Parsen. /// public sealed record ImportDetection { public int Confidence { get; } public string? Reason { get; } public bool IsMatch => Confidence > 0; public ImportDetection(int confidence, string? reason = null) { if (confidence is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(confidence)); Confidence = confidence; Reason = reason; } public static ImportDetection NoMatch(string? reason = null) => new(0, reason); public static ImportDetection Match(int confidence, string? reason = null) => new(confidence, reason); } public sealed class ImportParseResult { public IReadOnlyList Items { get; } public IReadOnlyList Messages { get; } public bool IsValid => Messages.All(message => message.Severity != ImportMessageSeverity.Error); public ImportParseResult(IEnumerable items, IEnumerable? messages = null) { ArgumentNullException.ThrowIfNull(items); Items = ReadOnly(items); Messages = ReadOnly(messages ?? []); } private static IReadOnlyList ReadOnly(IEnumerable values) => new ReadOnlyCollection(values.ToList()); } /// /// Ein registrierbarer, UI-unabhängiger Handler für genau ein Dateiformat und ein /// normalisiertes fachliches Importmodell. /// public interface IImportHandler { ImportFormatId FormatId { get; } string DisplayName { get; } IReadOnlyCollection SupportedExtensions { get; } ValueTask DetectAsync( ImportFile file, CancellationToken cancellationToken = default); ValueTask> ParseAsync( ImportFile file, CancellationToken cancellationToken = default); } public sealed record DetectedImportHandler( IImportHandler Handler, ImportDetection Detection); public abstract class ImportFormatException(string message) : Exception(message); public sealed class UnsupportedImportFormatException(string fileName) : ImportFormatException($"Für die Datei „{fileName}“ wurde kein passendes Importformat gefunden."); public sealed class AmbiguousImportFormatException(string fileName, IEnumerable formats) : ImportFormatException( $"Die Datei „{fileName}“ passt zu mehreren Importformaten: {string.Join(", ", formats)}."); /// /// Verwaltet Handler eines fachlichen Importziels und wählt anhand des Dateiinhalts /// den besten Treffer. Die Dateiendung dient bei gleicher Erkennungsqualität als Hinweis. /// public sealed class ImportHandlerCatalog { private readonly IReadOnlyList> _handlers; public ImportHandlerCatalog(IEnumerable> handlers) { ArgumentNullException.ThrowIfNull(handlers); var materialized = handlers.ToList(); var duplicate = materialized .GroupBy(handler => handler.FormatId) .FirstOrDefault(group => group.Count() > 1); if (duplicate is not null) throw new ArgumentException( $"Das Importformat „{duplicate.Key}“ wurde mehrfach registriert.", nameof(handlers)); _handlers = new ReadOnlyCollection>(materialized); } public IReadOnlyList> Handlers => _handlers; public IReadOnlyList SupportedExtensions => _handlers .SelectMany(handler => handler.SupportedExtensions) .Select(NormalizeExtension) .Where(extension => extension.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(extension => extension, StringComparer.OrdinalIgnoreCase) .ToList() .AsReadOnly(); public async ValueTask> DetectAsync( ImportFile file, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(file); var matches = new List(); foreach (var handler in _handlers) { cancellationToken.ThrowIfCancellationRequested(); var detection = await handler.DetectAsync(file, cancellationToken).ConfigureAwait(false); if (detection.IsMatch) matches.Add(new Candidate(handler, detection, SupportsExtension(handler, file.Extension))); } if (matches.Count == 0) throw new UnsupportedImportFormatException(file.FileName); var highestConfidence = matches.Max(match => match.Detection.Confidence); var best = matches.Where(match => match.Detection.Confidence == highestConfidence).ToList(); if (best.Count > 1 && best.Any(match => match.ExtensionMatches)) best = best.Where(match => match.ExtensionMatches).ToList(); if (best.Count != 1) throw new AmbiguousImportFormatException( file.FileName, best.Select(match => match.Handler.DisplayName)); return new DetectedImportHandler(best[0].Handler, best[0].Detection); } private static bool SupportsExtension(IImportHandler handler, string extension) => handler.SupportedExtensions .Select(NormalizeExtension) .Contains(extension, StringComparer.OrdinalIgnoreCase); private static string NormalizeExtension(string extension) { if (string.IsNullOrWhiteSpace(extension)) return ""; var trimmed = extension.Trim().ToLowerInvariant(); return trimmed.StartsWith('.') ? trimmed : $".{trimmed}"; } private sealed record Candidate( IImportHandler Handler, ImportDetection Detection, bool ExtensionMatches); } public sealed record ImportConflictOption(string Id, string Label, string? Description = null); public sealed record ImportConflict( string Id, string Title, string Description, string ImportedValue, string? ExistingValue, IReadOnlyList Options, string DefaultOptionId, string? SourceReference = null); /// Vom UI getroffene, für den Core rein datenförmige Konfliktentscheidung. public sealed record ImportDecision(string ConflictId, string SelectedOptionId);