211 lines
7.6 KiB
C#
211 lines
7.6 KiB
C#
using System.Collections.ObjectModel;
|
|
|
|
namespace LehrerApp.Core.Importing;
|
|
|
|
/// <summary>
|
|
/// Stabile, typsichere Kennung eines Importformats. Werte werden normalisiert, damit
|
|
/// Groß-/Kleinschreibung oder versehentliche Leerzeichen nicht zu Tippfehlern führen.
|
|
/// </summary>
|
|
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 ?? "";
|
|
}
|
|
|
|
/// <summary>Eine vollständig in den Speicher geladene Importdatei.</summary>
|
|
public sealed class ImportFile
|
|
{
|
|
public string FileName { get; }
|
|
public ReadOnlyMemory<byte> Content { get; }
|
|
public string Extension => Path.GetExtension(FileName).ToLowerInvariant();
|
|
|
|
public ImportFile(string fileName, ReadOnlyMemory<byte> 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);
|
|
|
|
/// <summary>
|
|
/// Ergebnis einer schnellen Formaterkennung. 0 bedeutet „kein Treffer“, 100 einen
|
|
/// eindeutigen Treffer. Die eigentliche vollständige Prüfung erfolgt erst beim Parsen.
|
|
/// </summary>
|
|
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<T>
|
|
{
|
|
public IReadOnlyList<T> Items { get; }
|
|
public IReadOnlyList<ImportMessage> Messages { get; }
|
|
public bool IsValid => Messages.All(message => message.Severity != ImportMessageSeverity.Error);
|
|
|
|
public ImportParseResult(IEnumerable<T> items, IEnumerable<ImportMessage>? messages = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(items);
|
|
Items = ReadOnly(items);
|
|
Messages = ReadOnly(messages ?? []);
|
|
}
|
|
|
|
private static IReadOnlyList<TItem> ReadOnly<TItem>(IEnumerable<TItem> values) =>
|
|
new ReadOnlyCollection<TItem>(values.ToList());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ein registrierbarer, UI-unabhängiger Handler für genau ein Dateiformat und ein
|
|
/// normalisiertes fachliches Importmodell.
|
|
/// </summary>
|
|
public interface IImportHandler<T>
|
|
{
|
|
ImportFormatId FormatId { get; }
|
|
string DisplayName { get; }
|
|
IReadOnlyCollection<string> SupportedExtensions { get; }
|
|
|
|
ValueTask<ImportDetection> DetectAsync(
|
|
ImportFile file,
|
|
CancellationToken cancellationToken = default);
|
|
|
|
ValueTask<ImportParseResult<T>> ParseAsync(
|
|
ImportFile file,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public sealed record DetectedImportHandler<T>(
|
|
IImportHandler<T> 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<string> formats)
|
|
: ImportFormatException(
|
|
$"Die Datei „{fileName}“ passt zu mehreren Importformaten: {string.Join(", ", formats)}.");
|
|
|
|
/// <summary>
|
|
/// Verwaltet Handler eines fachlichen Importziels und wählt anhand des Dateiinhalts
|
|
/// den besten Treffer. Die Dateiendung dient bei gleicher Erkennungsqualität als Hinweis.
|
|
/// </summary>
|
|
public sealed class ImportHandlerCatalog<T>
|
|
{
|
|
private readonly IReadOnlyList<IImportHandler<T>> _handlers;
|
|
|
|
public ImportHandlerCatalog(IEnumerable<IImportHandler<T>> 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<IImportHandler<T>>(materialized);
|
|
}
|
|
|
|
public IReadOnlyList<IImportHandler<T>> Handlers => _handlers;
|
|
|
|
public IReadOnlyList<string> 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<DetectedImportHandler<T>> DetectAsync(
|
|
ImportFile file,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(file);
|
|
var matches = new List<Candidate>();
|
|
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<T>(best[0].Handler, best[0].Detection);
|
|
}
|
|
|
|
private static bool SupportsExtension(IImportHandler<T> 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<T> 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<ImportConflictOption> Options,
|
|
string DefaultOptionId,
|
|
string? SourceReference = null);
|
|
|
|
/// <summary>Vom UI getroffene, für den Core rein datenförmige Konfliktentscheidung.</summary>
|
|
public sealed record ImportDecision(string ConflictId, string SelectedOptionId);
|