Vervollständigt die Cardecky-Web-Parität für Deck- und Card-Workflows. γ-1+γ-2 (AI-Deck-Generierung) - 4-Modi-Picker im DeckEditorView Create-Sheet: Leer/KI/Bild/CSV - POST /api/v1/decks/generate für Text-Prompt + 10/min Rate-Limit-UI - POST /api/v1/decks/from-image mit PhotosPicker + PDF-Importer (max 5 Files, 10 MiB/Bild, 30 MiB/PDF), Multipart-Body in CardsAPI+Generation - Loading-Overlay mit Task-Cancellation, Error-Mapping für 429/413/502 γ-3 (Card-Edit) - CardEditorView mit Mode .create(deckId:) / .edit(card:) - Image-Occlusion + Audio-Front behalten bestehenden Media-Ref, solange User nicht ersetzt — MediaCache lädt Bild nach - Type-Picker im Edit-Modus aus (Server-immutable) - CardEditorPayload + CardEditorMediaFields als Sub-Views γ-4 (Pull-Update + Duplicate + Archive) - POST /marketplace/private/:id/pull-update mit Smart-Merge-Anzeige - POST /decks/:id/duplicate - Archive-Toggle im Edit-Modus, Server filtert Liste serverseitig - DeckSecondaryActions als eigenes Sub-View γ-6 (CSV-Import) - RFC-4180-ish Parser (Quote-Escape, Header-Detect, BOM-strip) - Preview-Liste + sequentielle Card-Inserts mit Live-Progress - Image-Occlusion/Audio-Front werden geskipped (UI flaggt) γ-7 (Marketplace-Publish) + Follow-up (Report + Block + Re-Publish) - MarketplacePublishView mit lazy Author-Setup + Init + Publish 1.0.0 - Re-Publish-Modus: Picker für eigene Marketplace-Decks + Auto-Semver-Bump (Minor +1) - MarketplaceCardConverter (typing → type-in, audio-front → skipped, image-occlusion → skipped — Server hat keinen MP-Media-Re-Upload) - Toolbar-Menü auf PublicDeckView: „Deck melden …" + Author-Blockieren (App-Store-Guideline 5.1.1(v)) - ReportDeckSheet mit Reason-Picker (6 Kategorien) + optional Message - BlockedAuthorsView in Settings mit Swipe-Entblocken γ-8 (PDF-Export) - DeckPrintView mit SFSafariViewController auf cardecky.mana.how/decks/:id/print — iOS Share-Sheet → PDF speichern Side-Fixes (mid-stream) - StudySessionView: Card-Aspect-Ratio springt nicht mehr beim Flip (Bottom-Bar in ZStack fixer Höhe) - RootView: Glass-Pille für „Neues Deck"-Accessory + .guest- und .twoFactorRequired-Cases nachgezogen - DeckListView: Account-Toolbar-Button entfernt (Account-Tab unten ist alleinige Anlaufstelle) 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 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)
|
||
}
|
||
}
|
||
}
|