feat(events): wordeck user-owned-Routen auf event-sourced Coordinator umleiten
E-4.4b Rewiring: WordeckAPIs decks/cards/reviews-Methoden delegieren jetzt über die @MainActor-Fassade WordeckData an WordeckEventCoordinator (lokaler EventLog + FSRS + sync2) statt an die toten REST-Routen (410). Views/Stores/ GradeQueue/StudySession bleiben unverändert — minimal-invasiv. duplicateDeck + distractors lokal ergänzt (Server-Endpoints kennen event-sourced Karten nicht). configure() beim App-Launch. Marketplace/Generate/Author bleiben REST. Build grün, 48/48 Tests grün. Kompilier-verifiziert — Study-Loop braucht noch Geräte-Verifikation. Offen: Datenmigration vor-Cutover gecachter Decks (CachedDeck→Events). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
579622e9dc
commit
3daca22477
3 changed files with 125 additions and 84 deletions
|
|
@ -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)")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue