wordeck-native/Sources/Core/Domain/CSVParser.swift
Till JS 542082772a refactor(big-bang): cards-native → wordeck-native
Code + Identity-Rename zur Vorbereitung auf Apple-Dev-Portal-Aktion
(Bundle ev.mana.wordeck, App-Group group.ev.mana.wordeck, AASA
applinks:wordeck.com). Build bleibt funktional, aber gegen die
neue text-only-API können image-occlusion-Creates 422 zurückgeben —
das wird mit der Wordeck-Native v1.0-Welle (parallele Apple-Aktion)
sauber gemacht.

Umbenennung:
- 41 Files: cardecky/Cardecky → wordeck/Wordeck (Display, Strings,
  Kommentare)
- 57 Files: CardsNative → WordeckNative, CardsAPI → WordeckAPI,
  CardsTheme → WordeckTheme, CardsBrand → WordeckBrand, CardsWidget →
  WordeckWidget, CardsDueWidget → WordeckDueWidget
- Bundle-ID ev.mana.cardecky → ev.mana.wordeck (project.yml,
  Info.plist, entitlements, Keychain-Service, App-Group)
- AASA applinks:cardecky.mana.how → applinks:wordeck.com
- API-Base cardecky-api.mana.how → api.wordeck.com
- 10 Files renamed (App-Entry, API-Extensions, Theme, Widget,
  Entitlements, Tests)
- xcodeproj regenerated via xcodegen → WordeckNative.xcodeproj
- MaskRegionsTests.swift gelöscht (image-occlusion entfällt mit
  Wordeck text-only)

Forgejo-Repo git.mana.how/till/cards-native → wordeck-native umbenannt
(Auto-Redirect aktiv). Lokales Verzeichnis Code/cards-native/ bleibt
vorerst — wird beim nächsten Apple-Setup mit Bundle-Test umbenannt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 23:10:42 +02:00

164 lines
5.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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:
///
/// <question>,<answer>[,<type>]
///
/// - 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 Wordeck-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)
}
}
}