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>
164 lines
5.5 KiB
Swift
164 lines
5.5 KiB
Swift
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)
|
||
}
|
||
}
|
||
}
|