import Foundation /// CSV-Zeile aus dem Import-Flow. `type` ist optional — fehlt es, /// wird `.basic` angenommen. struct CSVRow: Equatable { let front: String let back: String let type: CardType init(front: String, back: String, type: CardType = .basic) { self.front = front self.back = back self.type = type } } /// Pragmatischer CSV-Parser für den Cards-Import. Format pro Zeile: /// /// ,[,] /// /// - Quote-Escape via `""` (RFC-4180). /// - Felder dürfen Kommas und Newlines enthalten, wenn sie in `"…"` /// gekapselt sind. /// - Header-Row wird automatisch übersprungen, wenn Front/Back beide /// wie Header-Tokens aussehen (`front`, `back`, `question`, `answer`, /// `vorderseite`, `rückseite` …). /// - BOM (`\u{FEFF}`) am Anfang wird gestrippt. /// - `type` darf jede Cardecky-Type-Bezeichnung sein; unbekannte Werte /// landen als `.basic`. enum CSVParser { enum ParseError: LocalizedError { case empty case noValidRows var errorDescription: String? { switch self { case .empty: "Datei ist leer." case .noValidRows: "Keine gültigen Zeilen gefunden — erwartet ‚vorne,hinten[,typ]'." } } } static func parse(_ rawText: String) throws -> [CSVRow] { var text = rawText if text.hasPrefix("\u{FEFF}") { text.removeFirst() } if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { throw ParseError.empty } let allRows = parseFields(text) guard !allRows.isEmpty else { throw ParseError.noValidRows } // Header-Detection: erste Zeile droppen wenn beide Felder Headerwords sind. let headerTokens: Set = [ "front", "back", "question", "answer", "vorderseite", "rückseite", "rueckseite", "frage", "antwort" ] var rows = allRows if let first = rows.first, first.count >= 2, headerTokens.contains(first[0].lowercased()), headerTokens.contains(first[1].lowercased()) { rows.removeFirst() } let parsed: [CSVRow] = rows.compactMap { fields in guard fields.count >= 2 else { return nil } let front = fields[0].trimmingCharacters(in: .whitespacesAndNewlines) let back = fields[1].trimmingCharacters(in: .whitespacesAndNewlines) if front.isEmpty, back.isEmpty { return nil } let type: CardType = fields.count >= 3 ? CardType(rawValue: fields[2].trimmingCharacters(in: .whitespacesAndNewlines)) ?? .basic : .basic return CSVRow(front: front, back: back, type: type) } if parsed.isEmpty { throw ParseError.noValidRows } return parsed } /// Parser-State-Machine: liest Zeichen-für-Zeichen, beachtet Quote- /// Modus für Kommas/Newlines innerhalb von `"…"`-Feldern. `""` wird /// als wörtliches `"` im Feld behandelt. private static func parseFields(_ text: String) -> [[String]] { var state = ParseState() var iterator = text.makeIterator() while let char = iterator.next() { if state.inQuotes { handleQuotedChar(char, iterator: &iterator, state: &state) } else if char == "\"", state.currentField.isEmpty { state.inQuotes = true } else { handleUnquotedChar(char, state: &state) } } // Tail-Flush — letzte Zeile ohne abschließendes Newline. if !state.currentField.isEmpty || !state.currentRow.isEmpty { state.currentRow.append(state.currentField) state.rows.append(state.currentRow) } return state.rows } /// Mutable State der Parse-Machine — als `inout`-Struct in die /// Char-Handler durchgereicht, damit die Parameter-Listen kompakt /// bleiben. fileprivate struct ParseState { var rows: [[String]] = [] var currentRow: [String] = [] var currentField = "" var inQuotes = false } /// Im Quote-Modus: `"` schließt das Feld oder escaped sich selbst, /// alles andere ist Inhalt. private static func handleQuotedChar( _ char: Character, iterator: inout String.Iterator, state: inout ParseState ) { guard char == "\"" else { state.currentField.append(char) return } if let next = iterator.next(), next == "\"" { state.currentField.append("\"") return } state.inQuotes = false // Das Zeichen nach dem End-Quote ist ein Separator (Komma/Newline // /EOF) — über den Unquoted-Handler routen. if let next = iterator.next() { handleUnquotedChar(next, state: &state) } } private static func handleUnquotedChar(_ char: Character, state: inout ParseState) { switch char { case ",": state.currentRow.append(state.currentField) state.currentField = "" case "\n": state.currentRow.append(state.currentField) state.rows.append(state.currentRow) state.currentField = "" state.currentRow = [] case "\r": // CRLF: `\r` schluken, `\n` macht den Row-Break. break case "\"" where state.currentField.isEmpty: state.inQuotes = true default: state.currentField.append(char) } } }