using System.Globalization; using System.Text; using LehrerApp.Core.Models; namespace LehrerApp.Core.Importing; /// /// Liest den tabulatorgetrennten Stammdatenexport mit Spalten wie „longName“, /// „foreName“, „birthDate“ und „klasse.name“. Die Datei trägt historisch die /// Endung .csv, obwohl als Trennzeichen ein Tabulator verwendet wird. /// public sealed class StudentMasterDataCsvImportHandler : IImportHandler { private static readonly string[] SignatureHeaders = ["longName", "foreName", "gender", "birthDate", "klasse.name", "externKey"]; private static readonly UTF8Encoding StrictUtf8 = new( encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); public ImportFormatId FormatId => StudentImportFormats.MasterDataCsv; public string DisplayName => "Schüler-Stammdatenliste"; public IReadOnlyCollection SupportedExtensions { get; } = [".csv"]; public ValueTask DetectAsync( ImportFile file, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); string text; try { text = StrictUtf8.GetString(file.Content.Span); } catch (DecoderFallbackException) { return ValueTask.FromResult(ImportDetection.NoMatch("Die Datei ist kein gültiges UTF-8.")); } var firstLineEnd = text.IndexOfAny(['\r', '\n']); var firstLine = (firstLineEnd >= 0 ? text[..firstLineEnd] : text).TrimStart('\uFEFF'); var headers = firstLine.Split('\t') .Select(header => header.Trim()) .ToHashSet(StringComparer.OrdinalIgnoreCase); if (!SignatureHeaders.All(headers.Contains)) return ValueTask.FromResult(ImportDetection.NoMatch("Die erwarteten Stammdatenspalten fehlen.")); return ValueTask.FromResult(ImportDetection.Match( file.Extension.Equals(".csv", StringComparison.OrdinalIgnoreCase) ? 100 : 95, "Die charakteristischen Stammdatenspalten wurden gefunden.")); } public ValueTask> ParseAsync( ImportFile file, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); var messages = new List(); string text; try { text = StrictUtf8.GetString(file.Content.Span); } catch (DecoderFallbackException) { messages.Add(new ImportMessage( ImportMessageSeverity.Error, "master-data.encoding", "Die Stammdatenliste ist nicht als gültiges UTF-8 gespeichert.")); return ValueTask.FromResult(new ImportParseResult([], messages)); } if (!DelimitedTextParser.TryParse(text, '\t', out var records, out var parseError)) { messages.Add(new ImportMessage( ImportMessageSeverity.Error, "master-data.structure", parseError ?? "Die Stammdatenliste ist syntaktisch ungültig.")); return ValueTask.FromResult(new ImportParseResult([], messages)); } if (records.Count == 0) { messages.Add(new ImportMessage( ImportMessageSeverity.Error, "master-data.empty", "Die Stammdatenliste ist leer.")); return ValueTask.FromResult(new ImportParseResult([], messages)); } var headers = records[0].Fields .Select((header, index) => (Name: header.Trim().TrimStart('\uFEFF'), Index: index)) .ToList(); var duplicateHeader = headers .GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase) .FirstOrDefault(group => group.Count() > 1); if (duplicateHeader is not null) messages.Add(new ImportMessage( ImportMessageSeverity.Error, "master-data.header.duplicate", $"Die Spalte „{duplicateHeader.Key}“ kommt mehrfach vor.", "Kopfzeile")); var columns = headers .GroupBy(header => header.Name, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First().Index, StringComparer.OrdinalIgnoreCase); foreach (var required in SignatureHeaders.Where(required => !columns.ContainsKey(required))) messages.Add(new ImportMessage( ImportMessageSeverity.Error, "master-data.header.missing", $"Die erforderliche Spalte „{required}“ fehlt.", "Kopfzeile")); if (messages.Any(message => message.Severity == ImportMessageSeverity.Error)) return ValueTask.FromResult(new ImportParseResult([], messages)); var students = new List(); foreach (var record in records.Skip(1)) { cancellationToken.ThrowIfCancellationRequested(); if (record.Fields.All(string.IsNullOrWhiteSpace)) continue; var source = $"Zeile {record.LineNumber}"; if (record.Fields.Count != headers.Count) { messages.Add(new ImportMessage( ImportMessageSeverity.Error, "master-data.columns", $"Die Zeile enthält {record.Fields.Count} statt {headers.Count} Spalten.", source)); continue; } var gender = ParseGender(Value(record, columns, "gender"), source, messages); var dateOfBirth = ParseDate(Value(record, columns, "birthDate"), source, messages); var externalId = FirstNotEmpty( Value(record, columns, "externKey"), Value(record, columns, "id")); students.Add(new ImportedStudent { FirstName = Value(record, columns, "foreName"), LastName = Value(record, columns, "longName"), Gender = gender, DateOfBirth = dateOfBirth, ExternalId = externalId, GroupName = NullIfWhiteSpace(Value(record, columns, "klasse.name")), Contact = new ImportedStudentContact { Email = NullIfWhiteSpace(Value(record, columns, "address.email")), MobilePhone = NullIfWhiteSpace(Value(record, columns, "address.mobile")), Phone = NullIfWhiteSpace(Value(record, columns, "address.phone")), City = NullIfWhiteSpace(Value(record, columns, "address.city")), PostalCode = NullIfWhiteSpace(Value(record, columns, "address.postCode")), Street = NullIfWhiteSpace(Value(record, columns, "address.street")), }, SourceReference = source, }); } return ValueTask.FromResult(new ImportParseResult(students, messages)); } private static Gender? ParseGender( string value, string source, ICollection messages) { if (string.IsNullOrWhiteSpace(value)) return null; return value.Trim().ToLowerInvariant() switch { "m" or "männlich" or "maennlich" => Gender.M, "w" or "weiblich" => Gender.W, "d" or "divers" => Gender.D, _ => UnknownGender(value, source, messages), }; } private static Gender? UnknownGender( string value, string source, ICollection messages) { messages.Add(new ImportMessage( ImportMessageSeverity.Warning, "master-data.gender.unknown", $"Das Geschlecht „{value.Trim()}“ ist unbekannt und wird nicht übernommen.", source)); return null; } private static DateOnly? ParseDate( string value, string source, ICollection messages) { if (string.IsNullOrWhiteSpace(value)) return null; if (DateOnly.TryParseExact( value.Trim(), "dd.MM.yyyy", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return date; messages.Add(new ImportMessage( ImportMessageSeverity.Error, "master-data.birth-date.invalid", $"Das Geburtsdatum „{value.Trim()}“ besitzt nicht das erwartete Format TT.MM.JJJJ.", source)); return null; } private static string Value( DelimitedRecord record, IReadOnlyDictionary columns, string column) => columns.TryGetValue(column, out var index) && index < record.Fields.Count ? record.Fields[index].Trim() : ""; private static string? FirstNotEmpty(params string[] values) => values.Select(NullIfWhiteSpace).FirstOrDefault(value => value is not null); private static string? NullIfWhiteSpace(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); private sealed record DelimitedRecord(int LineNumber, IReadOnlyList Fields); private static class DelimitedTextParser { public static bool TryParse( string text, char delimiter, out List records, out string? error) { records = []; error = null; var fields = new List(); var field = new StringBuilder(); var inQuotes = false; var line = 1; var recordStartLine = 1; for (var index = 0; index < text.Length; index++) { var current = text[index]; if (current == '"') { if (inQuotes && index + 1 < text.Length && text[index + 1] == '"') { field.Append('"'); index++; } else { inQuotes = !inQuotes; } continue; } if (current == delimiter && !inQuotes) { fields.Add(field.ToString()); field.Clear(); continue; } if ((current == '\r' || current == '\n') && !inQuotes) { if (current == '\r' && index + 1 < text.Length && text[index + 1] == '\n') index++; fields.Add(field.ToString()); field.Clear(); records.Add(new DelimitedRecord(recordStartLine, fields.ToList())); fields.Clear(); line++; recordStartLine = line; continue; } if (current == '\n') line++; field.Append(current); } if (inQuotes) { error = $"Ein Textfeld ab Zeile {recordStartLine} besitzt kein schließendes Anführungszeichen."; return false; } if (field.Length > 0 || fields.Count > 0) { fields.Add(field.ToString()); records.Add(new DelimitedRecord(recordStartLine, fields.ToList())); } return true; } } }