feat(decks): γ-1 bis γ-8 — AI/CSV-Import, Card-Edit, Pull-Update, Marketplace-Publish + Moderation + PDF

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>
This commit is contained in:
Till JS 2026-05-14 02:03:59 +02:00
parent 8ca7bd3636
commit 73f9081fa1
26 changed files with 3419 additions and 442 deletions

View file

@ -0,0 +1,114 @@
import Foundation
import ManaCore
/// AI-Deck-Generierung + Multipart-Helpers ausgelagert aus `CardsAPI`,
/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt.
extension CardsAPI {
/// `POST /api/v1/decks/generate` KI generiert Deck aus Prompt.
/// Rate-Limit serverseitig 10/min. Antwort dauert typisch 1060s
/// (synchron, kein Streaming).
func generateDeckFromText(_ body: DeckGenerateBody) async throws -> DeckGenerateResponse {
let data = try makeJSON(body)
let (responseData, http) = try await transport.request(
path: "/api/v1/decks/generate",
method: "POST",
body: data
)
try ensureOK(http, data: responseData)
return try decoder.decode(DeckGenerateResponse.self, from: responseData)
}
/// `POST /api/v1/decks/from-image` Vision-LLM generiert Deck aus
/// Bildern und/oder PDFs (max 5 Files, 10 MiB pro Bild, 30 MiB pro PDF)
/// und optional einer URL für Zusatz-Kontext. Rate-Limit 10/min.
/// Multipart-Body mit `file`-Parts (wiederholt) + Text-Felder.
func generateDeckFromMedia(
files: [GenerationMediaFile],
language: GenerationLanguage,
count: Int,
url: String?
) async throws -> DeckGenerateResponse {
let boundary = "cards-native-\(UUID().uuidString)"
let body = makeFromImageMultipartBody(
files: files,
language: language,
count: count,
url: url,
boundary: boundary
)
let (responseData, http) = try await transport.request(
path: "/api/v1/decks/from-image",
method: "POST",
body: body,
contentType: "multipart/form-data; boundary=\(boundary)"
)
try ensureOK(http, data: responseData)
return try decoder.decode(DeckGenerateResponse.self, from: responseData)
}
// MARK: - Multipart
/// Single-File-Multipart-Body für `/media/upload`.
func makeMultipartBody(
file: Data,
filename: String,
mimeType: String,
boundary: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
let header = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
"""
body.append(Data(header.utf8))
body.append(file)
body.append(Data(lineBreak.utf8))
body.append(Data("--\(boundary)--\(lineBreak)".utf8))
return body
}
/// Multi-File-Multipart-Body für `/decks/from-image` mehrere Files
/// unter dem Form-Feld `file` (Server liest sie via `getAll('file')`)
/// plus optional `language`, `count`, `url` als Text-Felder.
func makeFromImageMultipartBody(
files: [GenerationMediaFile],
language: GenerationLanguage,
count: Int,
url: String?,
boundary: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
func appendField(name: String, value: String) {
let part = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="\(name)"\(lineBreak)\(lineBreak)\
\(value)\(lineBreak)
"""
body.append(Data(part.utf8))
}
appendField(name: "language", value: language.rawValue)
appendField(name: "count", value: String(count))
if let url, !url.trimmingCharacters(in: .whitespaces).isEmpty {
appendField(name: "url", value: url)
}
for file in files {
let header = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="file"; filename="\(file.filename)"\(lineBreak)\
Content-Type: \(file.mimeType)\(lineBreak)\(lineBreak)
"""
body.append(Data(header.utf8))
body.append(file.data)
body.append(Data(lineBreak.utf8))
}
body.append(Data("--\(boundary)--\(lineBreak)".utf8))
return body
}
}

View file

@ -0,0 +1,59 @@
import Foundation
import ManaCore
/// Marketplace-Moderation und Self-Endpoints ausgelagert aus `CardsAPI`,
/// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt.
///
/// `transport`, `decoder`, `makeJSON`, `ensureOK` sind im Actor
/// internal-zugänglich.
extension CardsAPI {
/// `GET /api/v1/marketplace/me/decks` eigene Marketplace-Decks
/// (mit aktueller Version) für den Re-Publish-Flow.
func myMarketplaceDecks() async throws -> [OwnedMarketplaceDeck] {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/decks")
try ensureOK(http, data: data)
return try decoder.decode(OwnedMarketplaceDecksResponse.self, from: data).decks
}
/// `POST /api/v1/marketplace/decks/:slug/report` Meldung melden.
/// Idempotent: doppeltes Melden mit gleicher Kategorie liefert
/// `already_reported: true` ohne Fehler.
@discardableResult
func reportDeck(slug: String, body: ReportDeckBody) async throws -> ReportDeckResponse {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks/\(slug)/report",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(ReportDeckResponse.self, from: data)
}
/// `POST /api/v1/marketplace/authors/:slug/block` Author blockieren.
/// Decks dieses Authors verschwinden für den aufrufenden User aus
/// allen Marketplace-Listings.
func blockAuthor(slug: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/authors/\(slug)/block",
method: "POST"
)
try ensureOK(http, data: data)
}
/// `DELETE /api/v1/marketplace/authors/:slug/block`.
func unblockAuthor(slug: String) async throws {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/authors/\(slug)/block",
method: "DELETE"
)
try ensureOK(http, data: data)
}
/// `GET /api/v1/marketplace/me/blocks` eigene Block-Liste.
func myBlocks() async throws -> [BlockEntry] {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/me/blocks")
try ensureOK(http, data: data)
return try decoder.decode(BlockListResponse.self, from: data).blocks
}
}

View file

@ -1,11 +1,16 @@
import Foundation
import ManaCore
// swiftlint:disable file_length
// swiftlint:disable type_body_length
/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport`
/// aus ManaCore, der die Cardecky-Endpoints kennt.
/// aus ManaCore, der die Cardecky-Endpoints kennt. Marketplace-Moderation
/// + Self-Endpoints + AI-Generation sind in `CardsAPI+Marketplace.swift`
/// und `CardsAPI+Generation.swift` ausgelagert.
actor CardsAPI {
private let transport: AuthenticatedTransport
private let decoder: JSONDecoder
let transport: AuthenticatedTransport
let decoder: JSONDecoder
init(auth: AuthClient) {
transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth)
@ -97,7 +102,7 @@ actor CardsAPI {
var items: [URLQueryItem] = [
.init(name: "sort", value: sort.rawValue),
.init(name: "limit", value: "\(limit)"),
.init(name: "offset", value: "\(offset)"),
.init(name: "offset", value: "\(offset)")
]
if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty {
items.append(.init(name: "q", value: query))
@ -218,6 +223,87 @@ actor CardsAPI {
try ensureOK(http, data: data)
}
/// `POST /api/v1/decks/:id/duplicate` Server-seitige Kopie mit
/// "(Kopie)"-Suffix, ohne FSRS-Verlauf, ohne Marketplace-Pointer.
@discardableResult
func duplicateDeck(id: String) async throws -> Deck {
let (data, http) = try await transport.request(
path: "/api/v1/decks/\(id)/duplicate",
method: "POST"
)
try ensureOK(http, data: data)
return try decoder.decode(Deck.self, from: data)
}
/// `POST /api/v1/marketplace/private/:deckId/pull-update` Smart-Merge-
/// Pull. Holt neue/geänderte Karten aus der jüngsten Marketplace-Version
/// in das geforkte private Deck. Removed-Karten bleiben lokal (User-
/// Choice gewinnt). 422 wenn das Deck kein Fork ist.
func pullUpdate(deckId: String) async throws -> PullUpdateResponse {
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/private/\(deckId)/pull-update",
method: "POST"
)
try ensureOK(http, data: data)
return try decoder.decode(PullUpdateResponse.self, from: data)
}
// MARK: - Marketplace-Publish
/// `POST /api/v1/marketplace/authors/me` Author-Profil upserten.
/// Pflicht-Schritt vor dem ersten Deck-Init im Marketplace.
func upsertAuthor(_ body: AuthorUpsertBody) async throws {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/authors/me",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
}
/// `GET /api/v1/marketplace/authors/me` eigenes Author-Profil
/// lesen, gibt `nil` zurück wenn noch keins existiert.
func myAuthor() async throws -> Bool {
let (data, http) = try await transport.request(path: "/api/v1/marketplace/authors/me")
try ensureOK(http, data: data)
// Server liefert entweder Author-Objekt oder JSON-null.
if let raw = try? JSONSerialization.jsonObject(with: data), raw is NSNull {
return false
}
return true
}
/// `POST /api/v1/marketplace/decks` Marketplace-Deck-Init.
/// Erstellt nur Metadaten; Karten folgen via `publishMarketplaceVersion`.
@discardableResult
func initMarketplaceDeck(_ body: MarketplaceDeckInitBody) async throws -> PublicDeck {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(PublicDeck.self, from: data)
}
/// `POST /api/v1/marketplace/decks/:slug/publish` neue Version
/// publishen. Karten werden serverseitig durch AI-Moderation geschickt.
func publishMarketplaceVersion(
slug: String,
body: MarketplacePublishBody
) async throws -> MarketplacePublishResponse {
let payload = try makeJSON(body)
let (data, http) = try await transport.request(
path: "/api/v1/marketplace/decks/\(slug)/publish",
method: "POST",
body: payload
)
try ensureOK(http, data: data)
return try decoder.decode(MarketplacePublishResponse.self, from: data)
}
// MARK: - Card-Mutations
/// `POST /api/v1/cards` Karte anlegen. Server validiert `fields`
@ -293,37 +379,15 @@ actor CardsAPI {
// MARK: - JSON-Encoding
private func makeJSON<T: Encodable>(_ value: T) throws -> Data {
func makeJSON(_ value: some Encodable) throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(value)
}
// MARK: - Multipart
private func makeMultipartBody(
file: Data,
filename: String,
mimeType: String,
boundary: String
) -> Data {
var body = Data()
let lineBreak = "\r\n"
let header = """
--\(boundary)\(lineBreak)\
Content-Disposition: form-data; name="file"; filename="\(filename)"\(lineBreak)\
Content-Type: \(mimeType)\(lineBreak)\(lineBreak)
"""
body.append(header.data(using: .utf8) ?? Data())
body.append(file)
body.append(lineBreak.data(using: .utf8) ?? Data())
body.append("--\(boundary)--\(lineBreak)".data(using: .utf8) ?? Data())
return body
}
// MARK: - Helpers
private func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
func ensureOK(_ http: HTTPURLResponse, data: Data) throws {
guard (200 ..< 300).contains(http.statusCode) else {
let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error
throw AuthError.serverError(status: http.statusCode, code: nil, message: message)
@ -331,6 +395,8 @@ actor CardsAPI {
}
}
// swiftlint:enable type_body_length
private struct CardsServerError: Decodable {
let error: String?
}

View file

@ -0,0 +1,164 @@
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)
}
}
}

View file

@ -0,0 +1,62 @@
import Foundation
/// Body für `POST /api/v1/decks/generate` AI-Text-Generierung.
/// Aus `cards/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`.
struct DeckGenerateBody: Encodable {
let prompt: String
let language: GenerationLanguage
let count: Int
let url: String?
}
/// Sprache für AI-Deck-Generierung. Server akzeptiert `de` oder `en`.
enum GenerationLanguage: String, Codable, CaseIterable {
case de
case en
var label: String {
switch self {
case .de: "Deutsch"
case .en: "English"
}
}
}
/// Eine hochzuladende Datei für `POST /api/v1/decks/from-image`.
/// Wird als multipart-`file`-Part gesendet.
struct GenerationMediaFile: Identifiable {
let id: UUID
let data: Data
let filename: String
let mimeType: String
init(id: UUID = UUID(), data: Data, filename: String, mimeType: String) {
self.id = id
self.data = data
self.filename = filename
self.mimeType = mimeType
}
/// `application/pdf` PDF-Dokument, sonst Bild.
var isPDF: Bool {
mimeType == "application/pdf"
}
/// Größen-Label für die UI ("3.2 MB").
var sizeLabel: String {
ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
}
}
/// Response von beiden AI-Generate-Endpoints (`/decks/generate` und
/// `/decks/from-image`). Beide rufen serverseitig `insertGeneratedDeck`
/// und liefern dieselbe Shape.
struct DeckGenerateResponse: Decodable {
let deck: Deck
let cardsCreated: Int
enum CodingKeys: String, CodingKey {
case deck
case cardsCreated = "cards_created"
}
}

View file

@ -2,7 +2,7 @@ import Foundation
/// Body für `POST /api/v1/decks`. Aus `DeckCreateSchema` in
/// `cards/packages/cards-domain/src/schemas/deck.ts`.
struct DeckCreateBody: Encodable, Sendable {
struct DeckCreateBody: Encodable {
let name: String
let description: String?
let color: String?
@ -19,7 +19,7 @@ struct DeckCreateBody: Encodable, Sendable {
}
/// Body für `PATCH /api/v1/decks/:id`. Alle Felder optional plus `archived`.
struct DeckUpdateBody: Encodable, Sendable {
struct DeckUpdateBody: Encodable {
var name: String?
var description: String?
var color: String?
@ -36,3 +36,37 @@ struct DeckUpdateBody: Encodable, Sendable {
case archived
}
}
/// Kurze Marketplace-Version-Info: Semver + Version-ID.
struct PullUpdateVersion: Decodable {
let semver: String
let versionId: String
enum CodingKeys: String, CodingKey {
case semver
case versionId = "version_id"
}
}
/// Response von `POST /api/v1/marketplace/private/:deckId/pull-update`.
/// `up_to_date == true` heißt: keine neue Marketplace-Version verfügbar,
/// die anderen Counts sind dann 0.
struct PullUpdateResponse: Decodable {
let upToDate: Bool
let from: PullUpdateVersion?
let to: PullUpdateVersion?
let added: Int
let changed: Int
let removed: Int
let cardsInserted: Int?
enum CodingKeys: String, CodingKey {
case upToDate = "up_to_date"
case from
case to
case added
case changed
case removed
case cardsInserted = "cards_inserted"
}
}

View file

@ -0,0 +1,122 @@
import Foundation
/// Kategorien für Deck-Reports entspricht serverseitig
/// `report_category` Enum in `marketplace.deck_reports`.
enum ReportCategory: String, Codable, CaseIterable {
case spam
case copyright
case nsfw
case misinformation
case hate
case other
var label: String {
switch self {
case .spam: "Spam"
case .copyright: "Urheberrecht"
case .nsfw: "Anstößige Inhalte"
case .misinformation: "Falschinformation"
case .hate: "Hass / Diskriminierung"
case .other: "Sonstiges"
}
}
}
/// Body für `POST /api/v1/marketplace/decks/:slug/report`.
struct ReportDeckBody: Encodable {
let category: ReportCategory
let body: String?
let versionId: String?
let cardContentHash: String?
enum CodingKeys: String, CodingKey {
case category
case body
case versionId
case cardContentHash
}
}
/// Antwort vom Report-Endpoint.
struct ReportDeckResponse: Decodable {
let ok: Bool
let alreadyReported: Bool
let reportId: String?
enum CodingKeys: String, CodingKey {
case ok
case alreadyReported = "already_reported"
case reportId = "report_id"
}
}
/// Eintrag aus `GET /api/v1/marketplace/me/blocks`.
struct BlockEntry: Decodable, Identifiable {
let authorSlug: String
let displayName: String
let blockedAt: Date
var id: String {
authorSlug
}
enum CodingKeys: String, CodingKey {
case authorSlug = "author_slug"
case displayName = "display_name"
case blockedAt = "blocked_at"
}
}
struct BlockListResponse: Decodable {
let blocks: [BlockEntry]
}
/// Aktuelle Version eines `OwnedMarketplaceDeck` semver + Karten-Count.
struct OwnedMarketplaceVersion: Decodable {
let versionId: String
let semver: String
let cardCount: Int
let publishedAt: Date?
enum CodingKeys: String, CodingKey {
case versionId = "version_id"
case semver
case cardCount = "card_count"
case publishedAt = "published_at"
}
}
/// Eintrag aus `GET /api/v1/marketplace/me/decks` Re-Publish-Flow.
struct OwnedMarketplaceDeck: Decodable, Identifiable {
let slug: String
let title: String
let description: String?
let language: String?
let category: String?
let license: String
let priceCredits: Int
let isTakedown: Bool
let createdAt: Date
let latestVersion: OwnedMarketplaceVersion?
var id: String {
slug
}
enum CodingKeys: String, CodingKey {
case slug
case title
case description
case language
case category
case license
case priceCredits = "price_credits"
case isTakedown = "is_takedown"
case createdAt = "created_at"
case latestVersion = "latest_version"
}
}
struct OwnedMarketplaceDecksResponse: Decodable {
let decks: [OwnedMarketplaceDeck]
}

View file

@ -0,0 +1,116 @@
import Foundation
/// Body für `POST /api/v1/marketplace/authors/me` Upsert des
/// Author-Profils. Pflicht-Schritt vor dem ersten Deck-Init im
/// Marketplace.
struct AuthorUpsertBody: Encodable {
let slug: String
let displayName: String
let bio: String?
let avatarUrl: String?
let pseudonym: Bool?
enum CodingKeys: String, CodingKey {
case slug
case displayName
case bio
case avatarUrl
case pseudonym
}
}
/// Body für `POST /api/v1/marketplace/decks` Deck-Init.
/// Erstellt nur die Metadaten; Karten kommen mit der ersten `publish`.
struct MarketplaceDeckInitBody: Encodable {
let slug: String
let title: String
let description: String?
let language: String?
let license: String?
let priceCredits: Int?
let category: DeckCategory?
enum CodingKeys: String, CodingKey {
case slug
case title
case description
case language
case license
case priceCredits
case category
}
}
/// Eine Card-Payload-Zeile für `POST /:slug/publish`. Andere Type-
/// Namen als bei privaten Karten der Server nutzt `'type-in'` statt
/// `'typing'` und `'audio'` statt `'audio-front'`.
struct MarketplacePublishCard: Encodable {
let type: String
let fields: [String: String]
}
/// Body für `POST /api/v1/marketplace/decks/:slug/publish`.
struct MarketplacePublishBody: Encodable {
let semver: String
let changelog: String?
let cards: [MarketplacePublishCard]
}
/// Antwort von `POST /:slug/publish`. Enthält das aktualisierte Deck,
/// die neue Version und das AI-Moderation-Verdict.
struct MarketplacePublishResponse: Decodable {
let deck: PublicDeck
let version: PublicDeckVersion
let moderation: ModerationResult
struct ModerationResult: Decodable {
let verdict: String
let categories: [String]?
let model: String?
}
}
/// Liste von Cardecky-Marketplace-Lizenzen. Server akzeptiert beliebige
/// Strings 60 Zeichen wir bieten die kanonischen vier.
enum MarketplaceLicense: String, CaseIterable {
case personalUse = "Cardecky-Personal-Use-1.0"
case shareAlike = "Cardecky-Share-Alike-1.0"
case attribution = "Cardecky-Attribution-1.0"
case proOnly = "Cardecky-Pro-Only-1.0"
var label: String {
switch self {
case .personalUse: "Persönlicher Gebrauch"
case .shareAlike: "Share-Alike (CC-BY-SA-Stil)"
case .attribution: "Namensnennung (CC-BY-Stil)"
case .proOnly: "Nur für Cardecky-Pro (Bezahl-Decks)"
}
}
}
/// Konvertiert eine private `Card` in eine `MarketplacePublishCard`
/// mit dem korrekten Marketplace-Type und Feld-Mapping. Liefert `nil`,
/// wenn der Type im Marketplace nicht unterstützt wird (z.B. Image-
/// Occlusion und Audio-Front brauchen Media-Re-Uploads, das gibt es
/// im Marketplace-Publish-Flow heute nicht).
enum MarketplaceCardConverter {
static func convert(_ card: Card) -> MarketplacePublishCard? {
switch card.type {
case .basic, .basicReverse, .cloze, .multipleChoice:
return MarketplacePublishCard(type: card.type.rawValue, fields: card.fields)
case .typing:
// typing 'type-in' mit umgeschlüsselten Feldern.
let front = card.fields["front"] ?? ""
let answer = card.fields["answer"] ?? ""
return MarketplacePublishCard(
type: "type-in",
fields: ["question": front, "expected": answer]
)
case .imageOcclusion, .audioFront:
// Media-Refs zeigen auf user-private Media-IDs Marketplace-
// User können die nicht laden. Skip bis Server-seitig ein
// Media-Publish-Flow existiert.
return nil
}
}
}