diff --git a/Sources/App/WordeckNativeApp.swift b/Sources/App/WordeckNativeApp.swift index 98592c1..d1e9699 100644 --- a/Sources/App/WordeckNativeApp.swift +++ b/Sources/App/WordeckNativeApp.swift @@ -41,6 +41,10 @@ struct WordeckNativeApp: App { auth.bootstrap() _auth = State(initialValue: auth) _authGate = State(initialValue: ManaAuthGate(auth: auth)) + // Event-Sync-Coordinator initialisieren (E-4.4b): user-owned-Daten + // (decks/cards/reviews) laufen jetzt event-sourced über ManaEventSync + // statt über die toten REST-Routen. Crypto + Sync starten async. + WordeckEventCoordinator.configure(auth: auth) Log.app.info("Wordeck starting — auth status: \(String(describing: auth.status), privacy: .public)") } diff --git a/Sources/Core/API/WordeckAPI.swift b/Sources/Core/API/WordeckAPI.swift index 126a5c1..0429f27 100644 --- a/Sources/Core/API/WordeckAPI.swift +++ b/Sources/Core/API/WordeckAPI.swift @@ -30,30 +30,20 @@ actor WordeckAPI { /// Optional: `forkedFromMarketplaceOnly` filtert auf Inbox-Decks /// (für den Inbox-Banner). func listDecks(forkedFromMarketplaceOnly: Bool = false) async throws -> [Deck] { - var path = "/api/v1/decks" - if forkedFromMarketplaceOnly { - path += "?forked_from_marketplace=true" - } - let (data, http) = try await transport.request(path: path) - try ensureOK(http, data: data) - let body = try decoder.decode(DeckListResponse.self, from: data) - return body.decks + // Event-sourced (E-4.4b) — REST-Route ist 410. Siehe WordeckEventCoordinator. + try await WordeckData.listDecks(forkedFromMarketplaceOnly: forkedFromMarketplaceOnly) } /// `GET /api/v1/cards?deck_id=...` — Anzahl Karten in einem Deck. /// Web macht das pro Deck einzeln; identisches Pattern hier. func cardCount(deckId: String) async throws -> Int { - let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)") - try ensureOK(http, data: data) - return try decoder.decode(CardListResponse.self, from: data).total + try await WordeckData.cardCount(deckId: deckId) } /// `GET /api/v1/cards?deck_id=...` — komplette Liste der Karten /// für den Browse-Modus im DeckDetailView. func listCards(deckId: String) async throws -> [Card] { - let (data, http) = try await transport.request(path: "/api/v1/cards?deck_id=\(deckId)") - try ensureOK(http, data: data) - return try decoder.decode(CardListResponse.self, from: data).cards + try await WordeckData.listCards(deckId: deckId) } /// `GET /api/v1/decks/:deckId/distractors` — N zufällige Feldwerte @@ -65,20 +55,14 @@ actor WordeckAPI { field: String = "answer", count: Int = 3 ) async throws -> [String] { - let path = "/api/v1/decks/\(deckId)/distractors?card_id=\(cardId)&field=\(field)&count=\(count)" - let (data, http) = try await transport.request(path: path) - try ensureOK(http, data: data) - return try decoder.decode(DistractorsResponse.self, from: data).distractors + // Lokal gesampelt (E-4.4b) — Server kennt event-sourced Karten nicht. + try await WordeckData.distractors(deckId: deckId, cardId: cardId, field: field, count: count) } /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — Anzahl fälliger /// Reviews in einem Deck. func dueCount(deckId: String) async throws -> Int { - let (data, http) = try await transport.request( - path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=500" - ) - try ensureOK(http, data: data) - return try decoder.decode(DueReviewsResponse.self, from: data).total + try await WordeckData.dueCount(deckId: deckId) } // MARK: - Marketplace @@ -152,48 +136,25 @@ actor WordeckAPI { /// `POST /api/v1/decks` — Deck anlegen. @discardableResult func createDeck(_ body: DeckCreateBody) async throws -> Deck { - let data = try makeJSON(body) - let (responseData, http) = try await transport.request( - path: "/api/v1/decks", - method: "POST", - body: data - ) - try ensureOK(http, data: responseData) - return try decoder.decode(Deck.self, from: responseData) + try await WordeckData.createDeck(body) } /// `PATCH /api/v1/decks/:id` — Deck-Felder ändern. @discardableResult func updateDeck(id: String, body: DeckUpdateBody) async throws -> Deck { - let data = try makeJSON(body) - let (responseData, http) = try await transport.request( - path: "/api/v1/decks/\(id)", - method: "PATCH", - body: data - ) - try ensureOK(http, data: responseData) - return try decoder.decode(Deck.self, from: responseData) + try await WordeckData.updateDeck(id: id, body: body) } /// `DELETE /api/v1/decks/:id` — Deck löschen (kaskadiert Cards + Reviews). func deleteDeck(id: String) async throws { - let (data, http) = try await transport.request( - path: "/api/v1/decks/\(id)", - method: "DELETE" - ) - try ensureOK(http, data: data) + try await WordeckData.deleteDeck(id: id) } /// `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) + try await WordeckData.duplicateDeck(id: id) } /// `POST /api/v1/marketplace/private/:deckId/pull-update` — Smart-Merge- @@ -272,38 +233,20 @@ actor WordeckAPI { /// (1 für basic, 2 für basic-reverse, N für cloze). @discardableResult func createCard(_ body: CardCreateBody) async throws -> Card { - let data = try makeJSON(body) - let (responseData, http) = try await transport.request( - path: "/api/v1/cards", - method: "POST", - body: data - ) - try ensureOK(http, data: responseData) - return try decoder.decode(Card.self, from: responseData) + try await WordeckData.createCard(body) } /// `PATCH /api/v1/cards/:id` — nur `fields` und `media_refs` /// sind änderbar. @discardableResult func updateCard(id: String, body: CardUpdateBody) async throws -> Card { - let data = try makeJSON(body) - let (responseData, http) = try await transport.request( - path: "/api/v1/cards/\(id)", - method: "PATCH", - body: data - ) - try ensureOK(http, data: responseData) - return try decoder.decode(Card.self, from: responseData) + try await WordeckData.updateCard(id: id, body: body) } /// `DELETE /api/v1/cards/:id` — Karte + zugehörige Reviews löschen /// (Cascade auf DB-Ebene). func deleteCard(id: String) async throws { - let (data, http) = try await transport.request( - path: "/api/v1/cards/\(id)", - method: "DELETE" - ) - try ensureOK(http, data: data) + try await WordeckData.deleteCard(id: id) } // MARK: - Study @@ -311,11 +254,7 @@ actor WordeckAPI { /// `GET /api/v1/reviews/due?deck_id=...&limit=500` — fällige Reviews /// inklusive zugehöriger Card-Daten. Hot-Path für die Study-View. func dueReviews(deckId: String, limit: Int = 500) async throws -> [DueReview] { - let (data, http) = try await transport.request( - path: "/api/v1/reviews/due?deck_id=\(deckId)&limit=\(limit)" - ) - try ensureOK(http, data: data) - return try decoder.decode(DueReviewsListResponse.self, from: data).reviews + try await WordeckData.dueReviews(deckId: deckId, limit: limit) } /// `POST /api/v1/reviews/:cardId/:subIndex/grade` — gibt eine @@ -328,14 +267,7 @@ actor WordeckAPI { rating: Rating, reviewedAt: Date = .now ) async throws -> Review { - let body = try makeJSON(GradeReviewBody(rating: rating, reviewedAt: reviewedAt)) - let (data, http) = try await transport.request( - path: "/api/v1/reviews/\(cardId)/\(subIndex)/grade", - method: "POST", - body: body - ) - try ensureOK(http, data: data) - return try decoder.decode(Review.self, from: data) + try await WordeckData.gradeReview(cardId: cardId, subIndex: subIndex, rating: rating, reviewedAt: reviewedAt) } // MARK: - JSON-Encoding diff --git a/Sources/Core/Sync/WordeckEventCoordinator.swift b/Sources/Core/Sync/WordeckEventCoordinator.swift index 8b5f9f8..0b48c16 100644 --- a/Sources/Core/Sync/WordeckEventCoordinator.swift +++ b/Sources/Core/Sync/WordeckEventCoordinator.swift @@ -217,6 +217,41 @@ final class WordeckEventCoordinator { ) } + /// Lokale Deck-Kopie (Server-Duplicate-Endpoint ist 410). Neues Deck + /// mit „(Kopie)"-Suffix + alle nicht-gelöschten Karten, ohne FSRS-Verlauf. + @discardableResult + func duplicateDeck(id: String) async throws -> Deck { + guard let src = try deckProjection(id) else { throw WordeckSyncError.notFound("deck:\(id)") } + let newDeck = try await createDeck(DeckCreateBody( + name: src.name + " (Kopie)", + description: src.description, + color: src.color, + category: src.category.flatMap(DeckCategory.init(rawValue:)), + visibility: nil + )) + for cardProj in try allCardProjections(deckId: id) { + _ = try await createCard(CardCreateBody( + deckId: newDeck.id, + type: CardType(rawValue: cardProj.type) ?? .basic, + fields: WordeckEventAdapters.parseFields(cardProj.fieldsJson) + )) + } + return newDeck + } + + /// Distraktoren für Multiple-Choice — lokal aus anderen Karten des Decks + /// gesampelt (Server-Endpoint kennt event-sourced Karten nicht mehr). + func distractors(deckId: String, cardId: String, field: String = "answer", count: Int = 3) throws -> [String] { + var values: [String] = [] + for cardProj in try allCardProjections(deckId: deckId) where cardProj.id != cardId { + let fields = WordeckEventAdapters.parseFields(cardProj.fieldsJson) + if let value = fields[field] ?? fields["answer"] ?? fields["back"], !value.isEmpty { + values.append(value) + } + } + return Array(Set(values).shuffled().prefix(count)) + } + // MARK: - Reviews func dueReviews(deckId: String, limit: Int = 500) throws -> [DueReview] { @@ -318,11 +353,81 @@ final class WordeckEventCoordinator { enum WordeckSyncError: LocalizedError { case notFound(String) case reducerGap(String) + case unavailable var errorDescription: String? { switch self { case let .notFound(id): "Nicht gefunden: \(id)" case let .reducerGap(id): "Reducer-Lücke: \(id)" + case .unavailable: "Event-Sync nicht initialisiert. Erst App neu starten." } } } + +/// `@MainActor`-Delegations-Fassade auf den geteilten Coordinator. Liefert +/// nur Sendable-Werte zurück, damit `WordeckAPI` (ein Actor) gefahrlos +/// `await WordeckData.x(...)` aufrufen kann — ohne den non-Sendable +/// Coordinator über die Actor-Grenze zu reichen. +@MainActor +enum WordeckData { + private static func coordinator() throws -> WordeckEventCoordinator { + guard let shared = WordeckEventCoordinator.shared else { throw WordeckSyncError.unavailable } + return shared + } + + static func listDecks(forkedFromMarketplaceOnly: Bool) throws -> [Deck] { + try coordinator().listDecks(forkedFromMarketplaceOnly: forkedFromMarketplaceOnly) + } + + static func cardCount(deckId: String) throws -> Int { + try coordinator().cardCount(deckId: deckId) + } + + static func listCards(deckId: String) throws -> [Card] { + try coordinator().listCards(deckId: deckId) + } + + static func dueCount(deckId: String) throws -> Int { + try coordinator().dueCount(deckId: deckId) + } + + static func distractors(deckId: String, cardId: String, field: String, count: Int) throws -> [String] { + try coordinator().distractors(deckId: deckId, cardId: cardId, field: field, count: count) + } + + static func createDeck(_ body: DeckCreateBody) async throws -> Deck { + try await coordinator().createDeck(body) + } + + static func updateDeck(id: String, body: DeckUpdateBody) async throws -> Deck { + try await coordinator().updateDeck(id: id, body: body) + } + + static func deleteDeck(id: String) async throws { + try await coordinator().deleteDeck(id: id) + } + + static func duplicateDeck(id: String) async throws -> Deck { + try await coordinator().duplicateDeck(id: id) + } + + static func createCard(_ body: CardCreateBody) async throws -> Card { + try await coordinator().createCard(body) + } + + static func updateCard(id: String, body: CardUpdateBody) async throws -> Card { + try await coordinator().updateCard(id: id, body: body) + } + + static func deleteCard(id: String) async throws { + try await coordinator().deleteCard(id: id) + } + + static func dueReviews(deckId: String, limit: Int) throws -> [DueReview] { + try coordinator().dueReviews(deckId: deckId, limit: limit) + } + + static func gradeReview(cardId: String, subIndex: Int, rating: Rating, reviewedAt: Date) async throws -> Review { + try await coordinator().gradeReview(cardId: cardId, subIndex: subIndex, rating: rating, reviewedAt: reviewedAt) + } +}