import Foundation import ManaCore /// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` /// aus ManaCore, der die Cardecky-Endpoints kennt. actor CardsAPI { private let transport: AuthenticatedTransport private let decoder: JSONDecoder init(auth: AuthClient) { transport = AuthenticatedTransport(baseURL: AppConfig.apiBaseURL, auth: auth) decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601withFractional } /// Health-Probe — verifiziert dass cardecky-api erreichbar ist /// und der eigene JWT akzeptiert wird. func healthCheck() async throws -> Bool { let (_, http) = try await transport.request(path: "/healthz") return http.statusCode == 200 } // MARK: - Decks /// `GET /api/v1/decks?archived=false` — alle aktiven Decks des Users. /// 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 } /// `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 } /// `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 } /// `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 } // MARK: - Marketplace /// `GET /api/v1/marketplace/explore` — Featured + Trending. func explore() async throws -> ExploreResponse { let (data, http) = try await transport.request(path: "/api/v1/marketplace/explore") try ensureOK(http, data: data) return try decoder.decode(ExploreResponse.self, from: data) } /// `GET /api/v1/marketplace/decks` — Browse mit Filtern. func browseMarketplace( query: String? = nil, sort: MarketplaceSort = .recent, language: String? = nil, limit: Int = 20, offset: Int = 0 ) async throws -> BrowseResponse { var items: [URLQueryItem] = [ .init(name: "sort", value: sort.rawValue), .init(name: "limit", value: "\(limit)"), .init(name: "offset", value: "\(offset)"), ] if let query, !query.trimmingCharacters(in: .whitespaces).isEmpty { items.append(.init(name: "q", value: query)) } if let language { items.append(.init(name: "language", value: language)) } var components = URLComponents() components.queryItems = items let queryString = components.percentEncodedQuery ?? "" let path = "/api/v1/marketplace/decks?\(queryString)" let (data, http) = try await transport.request(path: path) try ensureOK(http, data: data) return try decoder.decode(BrowseResponse.self, from: data) } /// `GET /api/v1/marketplace/decks/:slug`. func publicDeck(slug: String) async throws -> PublicDeckDetail { let (data, http) = try await transport.request(path: "/api/v1/marketplace/decks/\(slug)") try ensureOK(http, data: data) return try decoder.decode(PublicDeckDetail.self, from: data) } /// `POST /api/v1/marketplace/decks/:slug/subscribe` — Auto-Fork /// passiert serverseitig, Response liefert `private_deck_id`. @discardableResult func subscribe(slug: String) async throws -> SubscribeResponse { let (data, http) = try await transport.request( path: "/api/v1/marketplace/decks/\(slug)/subscribe", method: "POST" ) try ensureOK(http, data: data) return try decoder.decode(SubscribeResponse.self, from: data) } /// `DELETE /api/v1/marketplace/decks/:slug/subscribe`. func unsubscribe(slug: String) async throws { let (data, http) = try await transport.request( path: "/api/v1/marketplace/decks/\(slug)/subscribe", method: "DELETE" ) try ensureOK(http, data: data) } // MARK: - Media /// `POST /api/v1/media/upload` — Multipart-Upload. Max 25 MiB. /// Erlaubte MIMEs: image/*, audio/*, video/*. func uploadMedia(data: Data, filename: String, mimeType: String) async throws -> MediaUploadResponse { let boundary = "cards-native-\(UUID().uuidString)" let body = makeMultipartBody( file: data, filename: filename, mimeType: mimeType, boundary: boundary ) let (response, http) = try await transport.request( path: "/api/v1/media/upload", method: "POST", body: body, contentType: "multipart/form-data; boundary=\(boundary)" ) try ensureOK(http, data: response) return try decoder.decode(MediaUploadResponse.self, from: response) } /// `GET /api/v1/media/:id` — streamt das Media-File. Antwortet mit /// raw bytes (kein JSON), Caller schreibt das auf Disk via MediaCache. func fetchMedia(id: String) async throws -> Data { let (data, http) = try await transport.request(path: "/api/v1/media/\(id)") guard (200 ..< 300).contains(http.statusCode) else { throw AuthError.serverError(status: http.statusCode, message: "media fetch failed") } return data } /// `DELETE /api/v1/media/:id` — Soft-Forget. (Endpoint heute nicht /// implementiert serverseitig; Stub bleibt für späteren Use.) func deleteMedia(id _: String) async throws { throw AuthError.serverError(status: 501, message: "media delete not implemented on server") } // MARK: - Deck-Mutations /// `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) } /// `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) } /// `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) } // MARK: - Card-Mutations /// `POST /api/v1/cards` — Karte anlegen. Server validiert `fields` /// gegen den Card-Type und erstellt automatisch Reviews /// (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) } /// `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) } /// `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) } // MARK: - Study /// `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 } /// `POST /api/v1/reviews/:cardId/:subIndex/grade` — gibt eine /// Bewertung ab. Server rechnet FSRS, antwortet mit aktualisiertem /// Review. @discardableResult func gradeReview( cardId: String, subIndex: Int, 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) } // MARK: - JSON-Encoding private func makeJSON(_ value: T) 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 { guard (200 ..< 300).contains(http.statusCode) else { let message = (try? JSONDecoder().decode(CardsServerError.self, from: data))?.error throw AuthError.serverError(status: http.statusCode, message: message) } } } private struct CardsServerError: Decodable { let error: String? } extension JSONDecoder.DateDecodingStrategy { /// Cards-API liefert ISO8601 mit Fractional-Seconds aus /// `.toISOString()`. Standard-Strategy `.iso8601` akzeptiert die /// fractional seconds nicht — wir nutzen einen eigenen Formatter. static let iso8601withFractional: JSONDecoder.DateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = f.date(from: raw) { return date } f.formatOptions = [.withInternetDateTime] if let date = f.date(from: raw) { return date } throw DecodingError.dataCorruptedError( in: container, debugDescription: "Cannot decode ISO8601 date: \(raw)" ) } }