From 9527240bcc6f4911578dfdc963858deefb3af8aa Mon Sep 17 00:00:00 2001 From: Till JS Date: Mon, 18 May 2026 22:06:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(offline):=20text-only=20Cleanup=20+=20?= =?UTF-8?q?=CE=B6-1=20Offline-Sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei zusammenhängende Blöcke in einem Commit (Files überlappen sich zwischen den Themen — sauberer Split nicht ohne Friktion möglich): 1. Wordeck-Text-Only-Cleanup Image-Occlusion + Audio-Front-Code raus. Server ist seit Migration 0004_wordeck_text_only.sql text-only (in Prod waren 0 Karten der Typen, 0 Media-Files). Native-Code war Build-11-Altlast. - Gelöscht: MediaCache, MediaEnvironment, RemoteImage, AudioPlayerButton, MaskEditorView, CardEditorMediaFields, CardEditorPayload, Media.swift - CardType-Enum auf 5 Werte: basic / basic-reverse / cloze / typing / multiple-choice - media_refs aus Card, CardCreateBody, CardUpdateBody, call-sites - WordeckAPI.uploadMedia / .fetchMedia / .deleteMedia + Single-File- makeMultipartBody gestrichen - MarketplaceCardConverter ohne Media-Cases - CardRenderer ohne imageOcclusionView / audioFrontView 2. AI-Media-Mode raus /decks/from-image-Endpoint existiert serverseitig nicht (server registriert nur /decks/generate für Text-Prompts). Native-Aufrufe wären 404 — toter Code. - aiMedia-Case aus DeckEditorView.CreateMode, ModePicker auf 3 Optionen (Leer / KI / CSV) - AIMediaFormSections, MediaFileRow, mediaPickers, thumbnail, ingestPhotoItems, handlePDFImport raus - generateDeckFromMedia + makeFromImageMultipartBody raus - GenerationMediaFile-Struct + PhotosUI-Import + PlatformImage- typealias raus - NSPhotoLibraryUsageDescription aus project.yml entfernt (es gibt keinen Photo-Library-Zugriff mehr) - maxMediaFiles/maxImageBytes/maxPDFBytes + inferImageMimeType + imageExtension aus DeckEditorHelpers raus 3. ζ-1 Offline-Sync Konzept in docs/OFFLINE_SYNC.md. Server-authoritative-FSRS bleibt — kein lokales FSRS, nur Snapshot-Modell. - Neue SwiftData-Models: CachedCard + CachedDueReview, beide mit userId/deckId-Indizes - ModelContainer um die zwei Models erweitert (additive Migration, sollte automatisch laufen — vor TestFlight verifizieren) - DueReview bekommt programmatischen init(review:card:) für die Cache-Rekonstruktion - DeckListStore.refresh() zieht Cards + Due-Reviews pro Deck parallel in einer TaskGroup; applyToCache in drei Helpers gesplittet (applyDecks / applyCards / applyDueReviews) - Karten: Upsert mit Orphan-Cleanup - Due-Reviews: voll ersetzt pro Refresh (Server-`due`-Zeiten ändern sich, Merge wäre falsch) - StudySession.start() fällt bei Netz-Fehler auf CachedDueReview-Snapshot zurück, setzt isOfflineSession-Flag - StudySessionView zeigt offline-Banner und am Ende der Session einen Hinweis „Weitere Karten erst nach Verbindung verfügbar" - AccountView.wipeLocalCache(): DSGVO-Wipe vor signOut() und nach deleteAccount → CachedDeck + CachedCard + CachedDueReview + PendingGrade werden gelöscht Plus: Keychain-Test in WordeckNativeTests.swift fix — erwartete "ev.mana.wordeck", muss seit Cross-App-SSO-Commit 19fee75 ManaSharedKeychainGroup nutzen. Auf Konstant-Reference umgestellt, damit's nicht wieder driftet. Verifikation: - xcodebuild iOS-Simulator: BUILD SUCCEEDED - swiftlint --strict: 0 violations in 68 files - swiftformat: clean - 37/37 Tests grün (inkl. fix-Keychain-Test) - macOS-Build scheitert an pre-existing .topBarTrailing in StudySessionView (iOS-only API seit 2026-05-13, nicht durch diesen Commit verursacht) Pflicht-Verifikation vor TestFlight (in PLAN.md verewigt): - SwiftData-Migration auf Bestandsbuilder - Offline-Endurance (50+ Karten Flugmodus) - Logout-Wipe mit Account-Switch - Cross-Check Web ↔ Native nach Offline-Grade Diff: 35 files, +869 / -1622, netto ~−750 LOC. Co-Authored-By: Claude Opus 4.7 (1M context) --- PLAN.md | 27 ++ Sources/App/WordeckNativeApp.swift | 10 +- Sources/Core/API/WordeckAPI+Generation.swift | 96 +------ Sources/Core/API/WordeckAPI.swift | 41 +-- Sources/Core/Domain/Card.swift | 16 +- Sources/Core/Domain/CardMutations.swift | 8 +- Sources/Core/Domain/DeckGeneration.swift | 32 +-- Sources/Core/Domain/MarketplacePublish.swift | 13 +- Sources/Core/Domain/Media.swift | 105 -------- Sources/Core/Domain/Review.swift | 8 + Sources/Core/Storage/CachedCard.swift | 43 +++ Sources/Core/Storage/CachedDueReview.swift | 88 +++++++ Sources/Core/Sync/DeckListStore.swift | 139 ++++++---- Sources/Core/Sync/MediaCache.swift | 81 ------ Sources/Core/Sync/MediaEnvironment.swift | 5 - Sources/Features/Account/AccountView.swift | 29 ++- Sources/Features/Decks/DeckDetailView.swift | 10 - Sources/Features/Decks/DeckListView.swift | 64 ++--- .../Decks/PendingShareConsumeView.swift | 3 +- .../Editor/CSVImportFormSections.swift | 4 - .../Editor/CardEditorMediaFields.swift | 173 ------------- .../Features/Editor/CardEditorPayload.swift | 149 ----------- Sources/Features/Editor/CardEditorView.swift | 125 ++------- .../Features/Editor/DeckEditorHelpers.swift | 33 +-- Sources/Features/Editor/DeckEditorView.swift | 222 +--------------- Sources/Features/Editor/MaskEditorView.swift | 146 ----------- .../Features/Marketplace/ExploreView.swift | 46 ++-- .../Features/Media/AudioPlayerButton.swift | 75 ------ Sources/Features/Media/RemoteImage.swift | 72 ------ Sources/Features/Study/CardRenderer.swift | 86 +----- Sources/Features/Study/StudySession.swift | 42 ++- Sources/Features/Study/StudySessionView.swift | 29 +++ Tests/UnitTests/MutationEncodingTests.swift | 14 +- Tests/UnitTests/WordeckNativeTests.swift | 14 +- docs/OFFLINE_SYNC.md | 244 ++++++++++++++++++ project.yml | 1 - 36 files changed, 728 insertions(+), 1565 deletions(-) delete mode 100644 Sources/Core/Domain/Media.swift create mode 100644 Sources/Core/Storage/CachedCard.swift create mode 100644 Sources/Core/Storage/CachedDueReview.swift delete mode 100644 Sources/Core/Sync/MediaCache.swift delete mode 100644 Sources/Core/Sync/MediaEnvironment.swift delete mode 100644 Sources/Features/Editor/CardEditorMediaFields.swift delete mode 100644 Sources/Features/Editor/CardEditorPayload.swift delete mode 100644 Sources/Features/Editor/MaskEditorView.swift delete mode 100644 Sources/Features/Media/AudioPlayerButton.swift delete mode 100644 Sources/Features/Media/RemoteImage.swift create mode 100644 docs/OFFLINE_SYNC.md diff --git a/PLAN.md b/PLAN.md index 1a50c47..c6b595c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -234,6 +234,33 @@ ausgeliefert wird — heute 404. Web-seitige Aufgabe. | β-5 | ✅ 2026-05-13 | Marketplace (Explore/Browse/Subscribe) + TabBar + Universal-Link-Handler (AASA server-side pending) | | β-6 | ✅ 2026-05-13 | Keyboard-Shortcuts + Daily-Reminders + WidgetKit (Siri/Share deferred auf β-7) | | β-7 | ✅ 2026-05-13 | App-Icon-Platzhalter + Siri-Shortcut + Share-Extension + Release-Checklist (externe Apple-Schritte siehe docs/RELEASE_CHECKLIST.md) | +| **Wordeck-Cleanup** | ✅ 2026-05-18 | Image-Occlusion + Audio-Front-Code raus (Server seit Migration `0004_wordeck_text_only.sql` text-only). Gelöscht: MediaCache, MediaEnvironment, RemoteImage, AudioPlayerButton, MaskEditorView, CardEditorMediaFields, CardEditorPayload, Media.swift. CardType-Enum auf 5 Werte reduziert, `media_refs` aus Card+CardCreateBody+CardUpdateBody+CardCreate-Call-Sites raus, `WordeckAPI.uploadMedia/.fetchMedia/.deleteMedia` raus, `makeMultipartBody` (Single-File) raus. | +| **AI-Media-raus** | ✅ 2026-05-18 | `/decks/from-image`-Endpoint existiert serverseitig gar nicht — gesamten Native-Code rausgenommen: `aiMedia`-Case + Sub-Sections in `DeckEditorView`, `generateDeckFromMedia` + `makeFromImageMultipartBody`, `GenerationMediaFile`-Struct, `PhotosUI`-Import, `PlatformImage`-typealias, `NSPhotoLibraryUsageDescription` aus `project.yml`. ModePicker auf 3 Optionen (Leer/KI/CSV). Auch Test fix: `WordeckNativeTests` nutzt jetzt `ManaSharedKeychainGroup` statt String-Literal. 37/37 Tests grün. | +| **ζ-1 (Offline-Sync)** | ✅ 2026-05-18 | `CachedCard` + `CachedDueReview` SwiftData-Models, `DeckListStore.refresh()` zieht Cards+Due-Reviews pro Deck parallel (TaskGroup) und ersetzt den Snapshot atomar. `StudySession.start()` fällt bei Netz-Fehler auf den Cache zurück, setzt `isOfflineSession`-Flag für UX-Banner. `DueReview` bekommt programmatischen `init(review:card:)` für die Rekonstruktion. `ModelContainer` um die zwei Models erweitert (additive Migration, sollte automatisch durchlaufen). DSGVO-Logout-Wipe in `AccountView`: vor jedem `signOut()` und nach `deleteAccount` werden `CachedDeck`+`CachedCard`+`CachedDueReview`+`PendingGrade` aus dem Context gelöscht. iOS-Build grün, swiftlint --strict clean, 37/37 Tests passen. | + +## Geplant: ζ-2..4 + +Konzept in [`docs/OFFLINE_SYNC.md`](docs/OFFLINE_SYNC.md). + +| Phase | Inhalt | Aufwand | +|---|---|---| +| ζ-2 | Distractor-Pool für MC-Karten (pro MC-Karte 10 Distractors mit-cachen) | 0,5 Tag | +| ζ-3 | `SettingsView`-Cache-Footprint anzeigen + manueller Cache-Clear | 0,5 Tag | +| ζ-4 (optional) | `BGAppRefreshTask`, Wi-Fi-Only-Toggle | 0,5 Tag | + +Server-authoritative-FSRS bleibt — kein lokales FSRS, nur Snapshot. + +## Pflicht-Verifikation für ζ-1 (Endurance auf realem Gerät) + +- [ ] **SwiftData-Migration:** alte App von TestFlight installieren, dann + über Xcode mit ζ-1-Build überschreiben — Cache muss durchlaufen, kein + Crash. (Additive Schema-Change sollte automatisch gehen, aber unverifiziert.) +- [ ] **Offline-Study:** 50+ Karten lernen mit Flugmodus, App killen, + neu öffnen, weiter lernen — alle Grades landen am Server nach Reconnect. +- [ ] **Logout-Wipe:** Abmelden, anderer Account anmelden — keine Karten/Decks + des Vorgängers in der DeckListView sichtbar. +- [ ] **Cross-Check mit Web:** Karte offline gegradet → Web zeigt identischen + Review-State nach Reload. ## Nächste Schritte: TestFlight + App-Store diff --git a/Sources/App/WordeckNativeApp.swift b/Sources/App/WordeckNativeApp.swift index 220182a..fe7e6bb 100644 --- a/Sources/App/WordeckNativeApp.swift +++ b/Sources/App/WordeckNativeApp.swift @@ -8,11 +8,15 @@ struct WordeckNativeApp: App { let container: ModelContainer @State private var auth: AuthClient @State private var authGate: ManaAuthGate - private let mediaCache: MediaCache init() { do { - container = try ModelContainer(for: CachedDeck.self, PendingGrade.self) + container = try ModelContainer( + for: CachedDeck.self, + CachedCard.self, + CachedDueReview.self, + PendingGrade.self + ) } catch { fatalError("Failed to init ModelContainer: \(error)") } @@ -20,7 +24,6 @@ struct WordeckNativeApp: App { auth.bootstrap() _auth = State(initialValue: auth) _authGate = State(initialValue: ManaAuthGate(auth: auth)) - mediaCache = MediaCache(api: WordeckAPI(auth: auth)) Log.app.info("Wordeck starting — auth status: \(String(describing: auth.status), privacy: .public)") } @@ -29,7 +32,6 @@ struct WordeckNativeApp: App { RootView() .environment(auth) .environment(authGate) - .environment(\.mediaCache, mediaCache) .tint(WordeckTheme.primary) } .modelContainer(container) diff --git a/Sources/Core/API/WordeckAPI+Generation.swift b/Sources/Core/API/WordeckAPI+Generation.swift index a95a444..a803a49 100644 --- a/Sources/Core/API/WordeckAPI+Generation.swift +++ b/Sources/Core/API/WordeckAPI+Generation.swift @@ -1,7 +1,7 @@ import Foundation import ManaCore -/// AI-Deck-Generierung + Multipart-Helpers — ausgelagert aus `WordeckAPI`, +/// AI-Deck-Generierung aus Text-Prompt — ausgelagert aus `WordeckAPI`, /// damit der Haupt-Actor unter der Type-Body-Length-Grenze bleibt. extension WordeckAPI { /// `POST /api/v1/decks/generate` — KI generiert Deck aus Prompt. @@ -17,98 +17,4 @@ extension WordeckAPI { 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 = "wordeck-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 - } } diff --git a/Sources/Core/API/WordeckAPI.swift b/Sources/Core/API/WordeckAPI.swift index f4915db..126a5c1 100644 --- a/Sources/Core/API/WordeckAPI.swift +++ b/Sources/Core/API/WordeckAPI.swift @@ -1,10 +1,9 @@ import Foundation import ManaCore -// swiftlint:disable file_length // swiftlint:disable type_body_length -/// Cards-spezifischer API-Client. Wrapper um `AuthenticatedTransport` +/// Wordeck-API-Client. Wrapper um `AuthenticatedTransport` /// aus ManaCore, der die Wordeck-Endpoints kennt. Marketplace-Moderation /// + Self-Endpoints + AI-Generation sind in `WordeckAPI+Marketplace.swift` /// und `WordeckAPI+Generation.swift` ausgelagert. @@ -148,44 +147,6 @@ actor WordeckAPI { 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 = "wordeck-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, code: nil, 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, code: nil, message: "media delete not implemented on server") - } - // MARK: - Deck-Mutations /// `POST /api/v1/decks` — Deck anlegen. diff --git a/Sources/Core/Domain/Card.swift b/Sources/Core/Domain/Card.swift index 9878d05..aad68b0 100644 --- a/Sources/Core/Domain/Card.swift +++ b/Sources/Core/Domain/Card.swift @@ -1,14 +1,16 @@ import Foundation -/// Card-DTO. Wire-Format aus `cards/apps/api/src/lib/dto.ts:toCardDto` -/// und `cards/packages/cards-domain/src/schemas/card.ts`. +/// Card-DTO. Wire-Format aus `wordeck/apps/api/src/lib/dto.ts:toCardDto` +/// und `wordeck/packages/wordeck-domain/src/schemas/card.ts`. +/// +/// Seit Wordeck-Rebrand (2026-05-17) text-only — kein `media_refs` +/// mehr im Schema. struct Card: Codable, Identifiable, Hashable { let id: String let deckId: String let userId: String let type: CardType let fields: [String: String] - let mediaRefs: [String] let contentHash: String? let createdAt: Date let updatedAt: Date @@ -19,22 +21,18 @@ struct Card: Codable, Identifiable, Hashable { case userId = "user_id" case type case fields - case mediaRefs = "media_refs" case contentHash = "content_hash" case createdAt = "created_at" case updatedAt = "updated_at" } } -/// Card-Type-Enum. Vollständig aus `CardTypeSchema`. In β-2 rendern -/// wir nur `basic`, `basic-reverse`, `cloze`. Die anderen Types -/// kommen in β-3 und β-4 dazu, sind aber jetzt schon decodierbar. +/// Card-Type-Enum. Wordeck ist text-only (Rebrand 2026-05-17) — +/// `image-occlusion` und `audio-front` sind aus dem Schema raus. enum CardType: String, Codable, CaseIterable { case basic case basicReverse = "basic-reverse" case cloze - case imageOcclusion = "image-occlusion" - case audioFront = "audio-front" case typing case multipleChoice = "multiple-choice" } diff --git a/Sources/Core/Domain/CardMutations.swift b/Sources/Core/Domain/CardMutations.swift index 716abe2..4003e09 100644 --- a/Sources/Core/Domain/CardMutations.swift +++ b/Sources/Core/Domain/CardMutations.swift @@ -8,31 +8,25 @@ import Foundation /// - cloze: `text` (mit `{{cN::...}}`-Clustern) /// - typing: `front`, `answer` /// - multiple-choice: `front`, `answer` -/// - image-occlusion: `image_ref`, `mask_regions` (β-4) -/// - audio-front: `audio_ref`, `back` (β-4) struct CardCreateBody: Encodable { let deckId: String let type: CardType let fields: [String: String] - let mediaRefs: [String]? enum CodingKeys: String, CodingKey { case deckId = "deck_id" case type case fields - case mediaRefs = "media_refs" } } -/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` und `media_refs` — +/// Body für `PATCH /api/v1/cards/:id`. Nur `fields` ist änderbar — /// Type und deck_id sind immutable (Server-Schema). struct CardUpdateBody: Encodable { var fields: [String: String]? - var mediaRefs: [String]? enum CodingKeys: String, CodingKey { case fields - case mediaRefs = "media_refs" } } diff --git a/Sources/Core/Domain/DeckGeneration.swift b/Sources/Core/Domain/DeckGeneration.swift index 1b4bc78..7b38648 100644 --- a/Sources/Core/Domain/DeckGeneration.swift +++ b/Sources/Core/Domain/DeckGeneration.swift @@ -1,7 +1,7 @@ import Foundation /// Body für `POST /api/v1/decks/generate` — AI-Text-Generierung. -/// Aus `cards/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`. +/// Aus `wordeck/apps/api/src/routes/decks-generate.ts:GenerateInputSchema`. struct DeckGenerateBody: Encodable { let prompt: String let language: GenerationLanguage @@ -22,35 +22,7 @@ enum GenerationLanguage: String, Codable, CaseIterable { } } -/// 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. +/// Response von `/decks/generate`. struct DeckGenerateResponse: Decodable { let deck: Deck let cardsCreated: Int diff --git a/Sources/Core/Domain/MarketplacePublish.swift b/Sources/Core/Domain/MarketplacePublish.swift index e19238a..8f2094b 100644 --- a/Sources/Core/Domain/MarketplacePublish.swift +++ b/Sources/Core/Domain/MarketplacePublish.swift @@ -43,7 +43,7 @@ struct MarketplaceDeckInitBody: Encodable { /// 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'`. +/// `'typing'`. struct MarketplacePublishCard: Encodable { let type: String let fields: [String: String] @@ -89,28 +89,19 @@ enum MarketplaceLicense: String, CaseIterable { } /// 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). +/// mit dem korrekten Marketplace-Type und Feld-Mapping. 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 } } } diff --git a/Sources/Core/Domain/Media.swift b/Sources/Core/Domain/Media.swift deleted file mode 100644 index fd47f86..0000000 --- a/Sources/Core/Domain/Media.swift +++ /dev/null @@ -1,105 +0,0 @@ -import Foundation - -/// Response von `POST /api/v1/media/upload`. -struct MediaUploadResponse: Decodable { - let id: String - let url: String - let mimeType: String - let kind: MediaKind - let sizeBytes: Int - let originalFilename: String? - - enum CodingKeys: String, CodingKey { - case id - case url - case mimeType = "mime_type" - case kind - case sizeBytes = "size_bytes" - case originalFilename = "original_filename" - } -} - -enum MediaKind: String, Codable { - case image - case audio - case video - case other -} - -/// Image-Occlusion-Mask-Region. -/// `mask_regions`-Feld ist ein JSON-Array-**String** in `fields`, -/// nicht ein Object — Server-Schema-Constraint (`fields: Record`). -struct MaskRegion: Codable, Hashable, Identifiable { - let id: String - let x: Double // 0..1 relativ - let y: Double - let w: Double - let h: Double - let label: String? - - init(id: String, x: Double, y: Double, w: Double, h: Double, label: String? = nil) { - self.id = id - self.x = x - self.y = y - self.w = w - self.h = h - self.label = label - } -} - -/// Helpers zum Parsen/Serialisieren von `mask_regions` als JSON-String. -enum MaskRegions { - /// 1:1-Port aus `cards-domain/image-occlusion.ts:parseMaskRegions`. - /// Bei Parse- oder Schema-Fehler: leere Liste. Sortiert nach ID - /// (lexikographisch, gleich wie Server-Sortierung). - static func parse(_ json: String) -> [MaskRegion] { - let data = Data(json.utf8) - guard let regions = try? JSONDecoder().decode([MaskRegion].self, from: data) else { return [] } - return regions.sorted { $0.id < $1.id } - } - - /// Sub-Index → Region (Sortier-Reihenfolge). - static func region(for json: String, subIndex: Int) -> MaskRegion? { - let all = parse(json) - return all.indices.contains(subIndex) ? all[subIndex] : nil - } - - /// Anzahl Regionen → Anzahl Sub-Index-Reviews. - static func count(_ json: String) -> Int { - parse(json).count - } - - /// Serialisiert eine Liste zu einem JSON-Array-String fürs `fields`-Feld. - static func encode(_ regions: [MaskRegion]) -> String { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(regions), - let json = String(bytes: data, encoding: .utf8) - else { return "[]" } - return json - } -} - -extension CardFieldsBuilder { - /// `image-occlusion`-Fields: `image_ref` (media_id) + - /// `mask_regions` (stringified JSON-Array) + optional `note`. - static func imageOcclusion( - imageRef: String, - regions: [MaskRegion], - note: String? = nil - ) -> [String: String] { - var fields: [String: String] = [ - "image_ref": imageRef, - "mask_regions": MaskRegions.encode(regions) - ] - if let note, !note.isEmpty { - fields["note"] = note - } - return fields - } - - /// `audio-front`-Fields: `audio_ref` (media_id) + `back` (Antwort-Text). - static func audioFront(audioRef: String, back: String) -> [String: String] { - ["audio_ref": audioRef, "back": back] - } -} diff --git a/Sources/Core/Domain/Review.swift b/Sources/Core/Domain/Review.swift index 77056c6..e3a0774 100644 --- a/Sources/Core/Domain/Review.swift +++ b/Sources/Core/Domain/Review.swift @@ -79,6 +79,14 @@ struct DueReview: Codable, Hashable, Identifiable { "\(review.cardId)-\(review.subIndex)" } + /// Programmatischer Memberwise-Init — fürs Rekonstruieren aus + /// `CachedDueReview` (offline-Fallback). Wird von Swift nicht + /// auto-synthesiert, weil der custom `init(from decoder:)` da ist. + init(review: Review, card: ReviewCard) { + self.review = review + self.card = card + } + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) // Flat-Decoding: Review-Felder + card-Objekt im selben JSON-Objekt diff --git a/Sources/Core/Storage/CachedCard.swift b/Sources/Core/Storage/CachedCard.swift new file mode 100644 index 0000000..34d1394 --- /dev/null +++ b/Sources/Core/Storage/CachedCard.swift @@ -0,0 +1,43 @@ +import Foundation +import SwiftData + +/// Lokales Cache-Model für eine einzelne Karte. Wird beim +/// `DeckListStore.refresh` mitgezogen, damit die Liste der Karten +/// auch offline verfügbar ist. Server bleibt Wahrheit — alle Edits +/// laufen über die API, der Cache wird nur beim Re-Fetch aktualisiert. +@Model +final class CachedCard { + @Attribute(.unique) var id: String + var deckId: String + var userId: String + var typeRaw: String + var fields: [String: String] + var contentHash: String? + var createdAt: Date + var updatedAt: Date + var lastFetchedAt: Date + + init(card: Card) { + id = card.id + deckId = card.deckId + userId = card.userId + typeRaw = card.type.rawValue + fields = card.fields + contentHash = card.contentHash + createdAt = card.createdAt + updatedAt = card.updatedAt + lastFetchedAt = .now + } + + func update(from card: Card) { + typeRaw = card.type.rawValue + fields = card.fields + contentHash = card.contentHash + updatedAt = card.updatedAt + lastFetchedAt = .now + } + + var type: CardType? { + CardType(rawValue: typeRaw) + } +} diff --git a/Sources/Core/Storage/CachedDueReview.swift b/Sources/Core/Storage/CachedDueReview.swift new file mode 100644 index 0000000..b0143f4 --- /dev/null +++ b/Sources/Core/Storage/CachedDueReview.swift @@ -0,0 +1,88 @@ +import Foundation +import SwiftData + +/// Snapshot eines `DueReview` (Review + Card-Subset) zum Zeitpunkt des +/// letzten Sync. Wird verwendet, wenn `StudySession` keine Verbindung +/// zum Server bekommt — der User lernt dann die Karten, die zum Sync- +/// Zeitpunkt fällig waren. +/// +/// Server-authoritative-FSRS bleibt: die `due`/`stability`/… Werte +/// kommen vom Server, lokal wird nie gerechnet. Beim nächsten Sync +/// liefert der Server eine neue Due-Liste. +@Model +final class CachedDueReview { + /// Eindeutiger Schlüssel: `"-"`. SwiftData braucht + /// einen primären Identifier pro Model, das natürliche compound- + /// Schlüssel auf cardId+subIndex. + @Attribute(.unique) var compoundId: String + + var cardId: String + var subIndex: Int + var deckId: String + var userId: String + + // Review-State (server-authoritative, hier nur Snapshot) + var due: Date + var stability: Double + var difficulty: Double + var elapsedDays: Double + var scheduledDays: Double + var learningSteps: Int + var reps: Int + var lapses: Int + var stateRaw: String + var lastReview: Date? + + // Card-Snapshot (für offline-Rendering — was die Study-View braucht) + var cardType: String + var cardFields: [String: String] + + var snapshottedAt: Date + + init(dueReview: DueReview, deckId: String, userId: String) { + compoundId = "\(dueReview.review.cardId)-\(dueReview.review.subIndex)" + cardId = dueReview.review.cardId + subIndex = dueReview.review.subIndex + self.deckId = deckId + self.userId = userId + due = dueReview.review.due + stability = dueReview.review.stability + difficulty = dueReview.review.difficulty + elapsedDays = dueReview.review.elapsedDays + scheduledDays = dueReview.review.scheduledDays + learningSteps = dueReview.review.learningSteps + reps = dueReview.review.reps + lapses = dueReview.review.lapses + stateRaw = dueReview.review.state.rawValue + lastReview = dueReview.review.lastReview + cardType = dueReview.card.type.rawValue + cardFields = dueReview.card.fields + snapshottedAt = .now + } + + /// Rekonstruiert einen `DueReview` für die `StudySession`-Queue. + /// Gibt `nil` zurück, wenn der Type/State im Enum nicht mehr existiert + /// (z.B. nach Schema-Migration). + func toDueReview() -> DueReview? { + guard let state = ReviewState(rawValue: stateRaw), + let type = CardType(rawValue: cardType) + else { return nil } + let review = Review( + cardId: cardId, + subIndex: subIndex, + userId: userId, + due: due, + stability: stability, + difficulty: difficulty, + elapsedDays: elapsedDays, + scheduledDays: scheduledDays, + learningSteps: learningSteps, + reps: reps, + lapses: lapses, + state: state, + lastReview: lastReview + ) + let card = ReviewCard(id: cardId, deckId: deckId, type: type, fields: cardFields) + return DueReview(review: review, card: card) + } +} diff --git a/Sources/Core/Sync/DeckListStore.swift b/Sources/Core/Sync/DeckListStore.swift index a56ce8c..f9e37c5 100644 --- a/Sources/Core/Sync/DeckListStore.swift +++ b/Sources/Core/Sync/DeckListStore.swift @@ -6,6 +6,10 @@ import WidgetKit /// Orchestriert API + SwiftData-Cache für die Deck-Liste. /// View bindet sich an `state` und `errorMessage`. +/// +/// Seit ζ-1 (2026-05-18) zieht der Store auch Karten + Due-Reviews +/// pro Deck mit (offline-Read für die Study-View). Siehe +/// `docs/OFFLINE_SYNC.md`. @MainActor @Observable final class DeckListStore { @@ -29,10 +33,9 @@ final class DeckListStore { self.auth = auth } - /// Holt Decks vom Server, aktualisiert Cache. Bei Netzfehler bleibt - /// der Cache (offline-readable). Im Guest-Mode wird kein Server-Call - /// versucht — der Cache (leer oder über Marketplace-Klone gefüllt) - /// wird so wie er ist gerendert. + /// Holt Decks + Karten + Due-Reviews vom Server, aktualisiert Cache. + /// Bei Netzfehler bleibt der Cache (offline-readable). Im Guest-Mode + /// wird kein Server-Call versucht. func refresh() async { guard case .signedIn = auth.status else { state = .idle @@ -45,7 +48,8 @@ final class DeckListStore { do { let decks = try await api.listDecks() - try await applyToCache(decks: decks) + let perDeck = try await fetchPerDeckPayloads(decks: decks) + try await applyToCache(decks: decks, perDeck: perDeck) updateWidgetSnapshot() state = .loaded Log.sync.info("Loaded \(decks.count, privacy: .public) decks from server") @@ -60,58 +64,103 @@ final class DeckListStore { } } - private func applyToCache(decks remoteDecks: [Deck]) async throws { - let remoteIDs = Set(remoteDecks.map(\.id)) + /// Snapshot pro Deck, geholt in einer parallelen TaskGroup. + private struct PerDeckPayload { + let cards: [Card] + let dueReviews: [DueReview] + } - // 1. Bestehende Cache-Entries finden - let descriptor = FetchDescriptor() - let cached = (try? context.fetch(descriptor)) ?? [] - let cachedByID = Dictionary(uniqueKeysWithValues: cached.map { ($0.id, $0) }) - - // 2. Gelöschte Decks aus Cache entfernen - for cachedDeck in cached where !remoteIDs.contains(cachedDeck.id) { - context.delete(cachedDeck) - } - - // 3. Counts parallel holen - let counts = await withTaskGroup(of: (String, Int, Int).self) { group in - for deck in remoteDecks { + private func fetchPerDeckPayloads(decks: [Deck]) async throws -> [String: PerDeckPayload] { + try await withThrowingTaskGroup(of: (String, PerDeckPayload).self) { group in + for deck in decks { group.addTask { [api] in - async let cards = api.cardCount(deckId: deck.id) - async let due = api.dueCount(deckId: deck.id) - let cardCount = await (try? cards) ?? 0 - let dueCount = await (try? due) ?? 0 - return (deck.id, cardCount, dueCount) + async let cards = api.listCards(deckId: deck.id) + async let due = api.dueReviews(deckId: deck.id, limit: 500) + return try await (deck.id, PerDeckPayload(cards: cards, dueReviews: due)) } } - var result: [String: (cardCount: Int, dueCount: Int)] = [:] - for await (id, c, d) in group { - result[id] = (c, d) + var result: [String: PerDeckPayload] = [:] + for try await (id, payload) in group { + result[id] = payload } return result } + } - // 4. Neue/aktualisierte Decks einarbeiten - for deck in remoteDecks { - let counts = counts[deck.id] ?? (0, 0) - if let existing = cachedByID[deck.id] { - existing.update(from: deck, cardCount: counts.cardCount, dueCount: counts.dueCount) - } else { - let cachedDeck = CachedDeck( - deck: deck, - cardCount: counts.cardCount, - dueCount: counts.dueCount - ) - context.insert(cachedDeck) - } - } - + private func applyToCache( + decks remoteDecks: [Deck], + perDeck: [String: PerDeckPayload] + ) async throws { + applyDecks(remoteDecks, perDeck: perDeck) + applyCards(remoteDecks, perDeck: perDeck) + applyDueReviews(remoteDecks, perDeck: perDeck) try context.save() } + private func applyDecks(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) { + let remoteIDs = Set(remoteDecks.map(\.id)) + let cachedDecks = (try? context.fetch(FetchDescriptor())) ?? [] + let cachedDeckByID = Dictionary(uniqueKeysWithValues: cachedDecks.map { ($0.id, $0) }) + + for cachedDeck in cachedDecks where !remoteIDs.contains(cachedDeck.id) { + context.delete(cachedDeck) + } + + for deck in remoteDecks { + let cardCount = perDeck[deck.id]?.cards.count ?? 0 + let dueCount = perDeck[deck.id]?.dueReviews.count ?? 0 + if let existing = cachedDeckByID[deck.id] { + existing.update(from: deck, cardCount: cardCount, dueCount: dueCount) + } else { + context.insert(CachedDeck(deck: deck, cardCount: cardCount, dueCount: dueCount)) + } + } + } + + /// Karten: Upsert pro remoteDeck, Orphans (Karten von gelöschten + /// Decks oder serverseits gelöschte Karten) löschen. + private func applyCards(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) { + let allCachedCards = (try? context.fetch(FetchDescriptor())) ?? [] + let cachedCardByID = Dictionary(uniqueKeysWithValues: allCachedCards.map { ($0.id, $0) }) + var remoteCardIDs: Set = [] + + for deck in remoteDecks { + guard let cards = perDeck[deck.id]?.cards else { continue } + for card in cards { + remoteCardIDs.insert(card.id) + if let existing = cachedCardByID[card.id] { + existing.update(from: card) + } else { + context.insert(CachedCard(card: card)) + } + } + } + for cachedCard in allCachedCards where !remoteCardIDs.contains(cachedCard.id) { + context.delete(cachedCard) + } + } + + /// Due-Reviews: Snapshot überschreibt komplett. Server-`due`-Zeiten + /// können sich ändern, also kein Merge — voll ersetzen. + private func applyDueReviews(_ remoteDecks: [Deck], perDeck: [String: PerDeckPayload]) { + let allCachedDues = (try? context.fetch(FetchDescriptor())) ?? [] + for cached in allCachedDues { + context.delete(cached) + } + for deck in remoteDecks { + guard let dues = perDeck[deck.id]?.dueReviews else { continue } + for due in dues { + context.insert(CachedDueReview( + dueReview: due, + deckId: deck.id, + userId: due.review.userId + )) + } + } + } + /// Schreibt einen WidgetSnapshot in den shared App-Group-Container - /// und fordert WidgetKit auf, alle Widgets neu zu rendern. Wird nach - /// jedem erfolgreichen Refresh aufgerufen. + /// und fordert WidgetKit auf, alle Widgets neu zu rendern. private func updateWidgetSnapshot() { let descriptor = FetchDescriptor( sortBy: [SortDescriptor(\.dueCount, order: .reverse)] diff --git a/Sources/Core/Sync/MediaCache.swift b/Sources/Core/Sync/MediaCache.swift deleted file mode 100644 index 2406f8d..0000000 --- a/Sources/Core/Sync/MediaCache.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Foundation -import ManaCore - -/// Persistenter Disk-Cache für Cards-Media-Files. Bilder/Audio werden -/// einmal vom Server geladen und danach lokal serviert — der Server -/// setzt `Cache-Control: private, immutable`, das honorieren wir hier. -/// -/// LRU-Verdrängung mit Soft-Limit (Default 200 MB). -actor MediaCache { - private let root: URL - private let api: WordeckAPI - private let maxBytes: Int - - init(api: WordeckAPI, maxBytes: Int = 200 * 1024 * 1024) { - self.api = api - self.maxBytes = maxBytes - let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - root = caches.appendingPathComponent("cards-media", isDirectory: true) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - } - - /// Liefert die lokale URL eines Media-Files. Lädt vom Server, falls - /// nicht im Cache. Wirft `AuthError`, wenn der Download scheitert. - func localURL(for mediaId: String) async throws -> URL { - let target = root.appendingPathComponent(mediaId) - if FileManager.default.fileExists(atPath: target.path) { - try? FileManager.default.setAttributes([.modificationDate: Date.now], ofItemAtPath: target.path) - return target - } - let data = try await api.fetchMedia(id: mediaId) - try data.write(to: target, options: .atomic) - try? await pruneIfNeeded() - return target - } - - /// Direktes Lesen — für UI-Komponenten, die `Data` brauchen (z.B. AVAudioPlayer). - func data(for mediaId: String) async throws -> Data { - try await Data(contentsOf: localURL(for: mediaId)) - } - - /// LRU-Eviction: bei Überschreitung des Limits ältesten zuerst löschen. - private struct CacheEntry { - let url: URL - let size: Int - let date: Date - } - - private func pruneIfNeeded() async throws { - let resourceKeys: Set = [.fileSizeKey, .contentModificationDateKey] - guard let items = try? FileManager.default.contentsOfDirectory( - at: root, - includingPropertiesForKeys: Array(resourceKeys) - ) else { return } - - let withMeta = items.compactMap { url -> CacheEntry? in - let values = try? url.resourceValues(forKeys: resourceKeys) - guard let size = values?.fileSize, let date = values?.contentModificationDate else { return nil } - return CacheEntry(url: url, size: size, date: date) - } - - let totalBytes = withMeta.reduce(0) { $0 + $1.size } - guard totalBytes > maxBytes else { return } - - let sortedOldestFirst = withMeta.sorted { $0.date < $1.date } - var remaining = totalBytes - for item in sortedOldestFirst { - if remaining <= maxBytes { break } - try? FileManager.default.removeItem(at: item.url) - remaining -= item.size - let name = item.url.lastPathComponent - let size = item.size - Log.sync.info("MediaCache evicted \(name, privacy: .public) (\(size, privacy: .public)B)") - } - } - - /// Wipe — für Sign-out o.ä. - func clear() { - try? FileManager.default.removeItem(at: root) - try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) - } -} diff --git a/Sources/Core/Sync/MediaEnvironment.swift b/Sources/Core/Sync/MediaEnvironment.swift deleted file mode 100644 index 566e3f7..0000000 --- a/Sources/Core/Sync/MediaEnvironment.swift +++ /dev/null @@ -1,5 +0,0 @@ -import SwiftUI - -extension EnvironmentValues { - @Entry var mediaCache: MediaCache? -} diff --git a/Sources/Features/Account/AccountView.swift b/Sources/Features/Account/AccountView.swift index 8c7093d..f947348 100644 --- a/Sources/Features/Account/AccountView.swift +++ b/Sources/Features/Account/AccountView.swift @@ -1,10 +1,12 @@ import ManaAuthUI import ManaCore +import SwiftData import SwiftUI struct AccountView: View { @Environment(AuthClient.self) private var auth @Environment(ManaAuthGate.self) private var authGate + @Environment(\.modelContext) private var context @State private var showChangeEmail = false @State private var showChangePassword = false @State private var showDeleteAccount = false @@ -46,7 +48,10 @@ struct AccountView: View { .sheet(isPresented: $showDeleteAccount) { ManaDeleteAccountView( auth: auth, - onDone: { showDeleteAccount = false } + onDone: { + Task { await wipeLocalCache() } + showDeleteAccount = false + } ) .manaBrand(WordeckBrand.manaBrand) } @@ -101,7 +106,15 @@ struct AccountView: View { // anonymen Modus nutzbar (lokale Decks, Marketplace // browsen). Wer „alles vergessen" will, nutzt // „Account löschen". - Task { await auth.signOut(keepGuestMode: true) } + // + // DSGVO: Cache (Karten + Due-Reviews + Decks + + // pending Grades) wird vor dem signOut gewipet, damit + // ein anderer User auf demselben Gerät keine Daten + // des Vorgängers sieht. + Task { + await wipeLocalCache() + await auth.signOut(keepGuestMode: true) + } } label: { Text("Abmelden") .frame(maxWidth: .infinity) @@ -178,6 +191,18 @@ struct AccountView: View { .padding(.top, 48) } + /// Löscht alle lokal gecachten User-Daten: Decks, Karten, fällige + /// Reviews und die offline Grade-Queue. Wird vor jedem signOut und + /// vor Account-Löschung aufgerufen. + private func wipeLocalCache() async { + try? context.delete(model: CachedDeck.self) + try? context.delete(model: CachedCard.self) + try? context.delete(model: CachedDueReview.self) + try? context.delete(model: PendingGrade.self) + try? context.save() + Log.app.info("Local cache wiped (signOut / delete-account)") + } + private func rowLabel(_ title: String, systemImage: String) -> some View { Label(title, systemImage: systemImage) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Sources/Features/Decks/DeckDetailView.swift b/Sources/Features/Decks/DeckDetailView.swift index c9e9000..6fec1d5 100644 --- a/Sources/Features/Decks/DeckDetailView.swift +++ b/Sources/Features/Decks/DeckDetailView.swift @@ -431,12 +431,6 @@ private struct CardPreviewRow: View { card.fields["front"] ?? "—" case .cloze: card.fields["text"] ?? "—" - case .imageOcclusion: - card.fields["note"]?.isEmpty == false - ? card.fields["note"]! - : "Bild-Verdeckung (\(MaskRegions.count(card.fields["mask_regions"] ?? "")) Masken)" - case .audioFront: - card.fields["back"] ?? "Audio-Karte" } } @@ -447,8 +441,6 @@ private struct CardPreviewRow: View { case .cloze: "text.append" case .typing: "keyboard" case .multipleChoice: "list.bullet" - case .imageOcclusion: "photo.on.rectangle.angled" - case .audioFront: "waveform" } } @@ -459,8 +451,6 @@ private struct CardPreviewRow: View { case .cloze: "Lückentext" case .typing: "Eintippen" case .multipleChoice: "Multiple Choice" - case .imageOcclusion: "Bild-Verdeckung" - case .audioFront: "Audio" } } } diff --git a/Sources/Features/Decks/DeckListView.swift b/Sources/Features/Decks/DeckListView.swift index 56cfd43..76edad0 100644 --- a/Sources/Features/Decks/DeckListView.swift +++ b/Sources/Features/Decks/DeckListView.swift @@ -42,41 +42,41 @@ struct DeckListView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .navigationDestination(for: DeckRoute.self) { route in - switch route { - case let .study(deckId, deckName): - StudySessionView(deckId: deckId, deckName: deckName) - case let .detail(deckId): - DeckDetailView(deckId: deckId) - } - } - .navigationDestination(for: PendingShareRoute.self) { route in - PendingShareConsumeView(share: route.share, onDone: { - PendingShareStore.remove(id: route.share.id) - pendingShares = PendingShareStore.readAll() - }) - } - .toolbar { toolbar } - .refreshable { - await store?.refresh() - } - .sheet(isPresented: $showCreate) { - NavigationStack { - DeckEditorView(mode: .create) { _ in - Task { await store?.refresh() } + .navigationDestination(for: DeckRoute.self) { route in + switch route { + case let .study(deckId, deckName): + StudySessionView(deckId: deckId, deckName: deckName) + case let .detail(deckId): + DeckDetailView(deckId: deckId) } } - } - .task { - if store == nil { - store = DeckListStore(auth: auth, context: context) + .navigationDestination(for: PendingShareRoute.self) { route in + PendingShareConsumeView(share: route.share, onDone: { + PendingShareStore.remove(id: route.share.id) + pendingShares = PendingShareStore.readAll() + }) + } + .toolbar { toolbar } + .refreshable { + await store?.refresh() + } + .sheet(isPresented: $showCreate) { + NavigationStack { + DeckEditorView(mode: .create) { _ in + Task { await store?.refresh() } + } + } + } + .task { + if store == nil { + store = DeckListStore(auth: auth, context: context) + } + await store?.refresh() + pendingShares = PendingShareStore.readAll() + } + .onAppear { + pendingShares = PendingShareStore.readAll() } - await store?.refresh() - pendingShares = PendingShareStore.readAll() - } - .onAppear { - pendingShares = PendingShareStore.readAll() - } } } diff --git a/Sources/Features/Decks/PendingShareConsumeView.swift b/Sources/Features/Decks/PendingShareConsumeView.swift index d869632..1b5359b 100644 --- a/Sources/Features/Decks/PendingShareConsumeView.swift +++ b/Sources/Features/Decks/PendingShareConsumeView.swift @@ -96,8 +96,7 @@ struct PendingShareConsumeView: View { let body = CardCreateBody( deckId: deckId, type: .basic, - fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText), - mediaRefs: nil + fields: CardFieldsBuilder.basic(front: front.trimmed, back: backText) ) do { _ = try await api.createCard(body) diff --git a/Sources/Features/Editor/CSVImportFormSections.swift b/Sources/Features/Editor/CSVImportFormSections.swift index d652b50..d33a909 100644 --- a/Sources/Features/Editor/CSVImportFormSections.swift +++ b/Sources/Features/Editor/CSVImportFormSections.swift @@ -34,8 +34,6 @@ struct CSVImportFormSections: View { preview } header: { Text("Vorschau (\(rows.count) Karten)") - } footer: { - Text("Image-Occlusion und Audio-Cards werden im CSV-Import übersprungen — die brauchen Datei-Uploads.") } } } @@ -75,8 +73,6 @@ struct CSVImportFormSections: View { case .cloze: "Lückentext" case .typing: "Eintippen" case .multipleChoice: "Multiple Choice" - case .imageOcclusion: "Bild-Verdeckung (übersprungen)" - case .audioFront: "Audio (übersprungen)" } } } diff --git a/Sources/Features/Editor/CardEditorMediaFields.swift b/Sources/Features/Editor/CardEditorMediaFields.swift deleted file mode 100644 index 1f30645..0000000 --- a/Sources/Features/Editor/CardEditorMediaFields.swift +++ /dev/null @@ -1,173 +0,0 @@ -import ManaCore -import PhotosUI -import SwiftUI - -/// Bild + Masken-Editor + Hinweis-Feld + Status für `image-occlusion`- -/// Cards. Owned-State: `imagePickerItem` (PhotosPicker-Bridge). Alles -/// andere lebt im Parent als `@State` und kommt hier als `@Binding` an. -/// -/// Beim Mount im Edit-Modus wird das bestehende Bild via `MediaCache` -/// nachgeladen, damit der User die existierenden Masken sieht. -struct ImageOcclusionFields: View { - @Binding var image: PlatformImage? - @Binding var imageData: Data? - @Binding var mimeType: String - @Binding var regions: [MaskRegion] - @Binding var note: String - @Binding var existingImageRef: String? - let onLoadError: (String) -> Void - - @Environment(\.mediaCache) private var mediaCache - @State private var pickerItem: PhotosPickerItem? - - var body: some View { - Section("Bild") { - PhotosPicker(selection: $pickerItem, matching: .images) { - ImagePickerLabel(hasImage: image != nil) - } - .onChange(of: pickerItem) { _, newItem in - Task { await loadPickedImage(newItem) } - } - } - - if let image { - Section("Masken") { - MaskEditorView(image: image, regions: $regions) - } - } - - Section("Hinweis (optional)") { - TextField("z.B. Kurz-Erklärung", text: $note, axis: .vertical) - .lineLimit(1 ... 3) - } - - Section { - statusLabel - } - .task(id: existingImageRef) { - await loadExistingImageIfNeeded() - } - } - - @ViewBuilder - private var statusLabel: some View { - if image == nil { - Label("Erst Bild wählen", systemImage: "info.circle") - .font(.caption) - .foregroundStyle(WordeckTheme.mutedForeground) - } else if regions.isEmpty { - Label("Mindestens eine Maske nötig", systemImage: "exclamationmark.circle") - .font(.caption) - .foregroundStyle(WordeckTheme.warning) - } else { - Label( - "\(regions.count) Masken → \(regions.count) Reviews", - systemImage: "checkmark.circle.fill" - ) - .font(.caption) - .foregroundStyle(WordeckTheme.success) - } - } - - private func loadExistingImageIfNeeded() async { - guard - image == nil, - let ref = existingImageRef, - let cache = mediaCache - else { return } - do { - let data = try await cache.data(for: ref) - if let img = PlatformImage(data: data) { - image = img - } - } catch { - onLoadError("Bestehendes Bild konnte nicht geladen werden: \(error.localizedDescription)") - } - } - - private func loadPickedImage(_ item: PhotosPickerItem?) async { - guard let item else { return } - do { - guard let data = try await item.loadTransferable(type: Data.self) else { return } - imageData = data - mimeType = inferImageMimeType(from: data) - if let img = PlatformImage(data: data) { - image = img - regions = [] // neue Bildauswahl resetet Masken - existingImageRef = nil // bestehender Ref wird ersetzt - } - } catch { - onLoadError("Bild konnte nicht geladen werden: \(error.localizedDescription)") - } - } - - private func inferImageMimeType(from data: Data) -> String { - guard data.count > 4 else { return "image/jpeg" } - let bytes = Array(data.prefix(8)) - if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" } - if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" } - if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" } - if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" } - return "image/jpeg" - } -} - -/// Datei-Picker + Antwort-Feld für `audio-front`-Cards. Owned-State: -/// `showAudioPicker`. URL und Antwort kommen als `@Binding` aus dem -/// Parent. -struct AudioFrontFields: View { - @Binding var audioFileURL: URL? - @Binding var back: String - let existingAudioRef: String? - - @State private var showPicker = false - - var body: some View { - Section("Audio-Datei") { - Button { - showPicker = true - } label: { - pickerLabel - } - .fileImporter( - isPresented: $showPicker, - allowedContentTypes: [.audio, .mp3, .wav, .mpeg4Audio], - allowsMultipleSelection: false - ) { result in - if case let .success(urls) = result, let first = urls.first { - audioFileURL = first - } - } - } - Section("Antwort") { - TextField("Was zu hören ist", text: $back, axis: .vertical) - .lineLimit(2 ... 4) - } - } - - @ViewBuilder - private var pickerLabel: some View { - if let audioFileURL { - Label(audioFileURL.lastPathComponent, systemImage: "waveform") - } else if existingAudioRef != nil { - Label("Audio ersetzen", systemImage: "waveform.badge.plus") - } else { - Label("Audio auswählen", systemImage: "waveform.badge.plus") - } - } -} - -/// PhotosPicker-Label als eigene View, damit Swift-6-Strict-Concurrency -/// nicht über den `@Sendable`-Closure meckert (View-Konstruktor-Calls -/// werden zur Build-Zeit MainActor-isoliert evaluiert). -struct ImagePickerLabel: View { - let hasImage: Bool - - var body: some View { - if hasImage { - Label("Bild ersetzen", systemImage: "arrow.triangle.2.circlepath") - } else { - Label("Bild auswählen", systemImage: "photo") - } - } -} diff --git a/Sources/Features/Editor/CardEditorPayload.swift b/Sources/Features/Editor/CardEditorPayload.swift deleted file mode 100644 index 7a3b947..0000000 --- a/Sources/Features/Editor/CardEditorPayload.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation -import ManaCore - -/// Resultat von `CardEditorPayload.build` — was an `WordeckAPI.createCard` -/// oder `updateCard` durchgereicht wird. -struct CardEditorPayload { - let fields: [String: String] - let mediaRefs: [String]? -} - -/// Snapshot der CardEditor-Felder zum Submit-Zeitpunkt. Ein Wert-Typ, -/// damit `buildPayload` außerhalb der View testbar ist und der View- -/// Struct kompakt bleibt. -struct CardEditorPayloadInputs { - let type: CardType - let front: String - let back: String - let clozeText: String - let typingAnswer: String - let multipleChoiceAnswer: String - let occlusionImageData: Data? - let occlusionMimeType: String - let occlusionRegions: [MaskRegion] - let occlusionNote: String - let existingImageRef: String? - let audioFileURL: URL? - let existingAudioRef: String? - let existingMediaRefs: [String] -} - -enum CardEditorPayloadError: LocalizedError { - case missingImage - case missingAudio - - var errorDescription: String? { - switch self { - case .missingImage: "Bitte ein Bild wählen." - case .missingAudio: "Bitte eine Audio-Datei wählen." - } - } -} - -enum CardEditorPayloadBuilder { - /// Baut den Payload für `POST /cards` bzw. `PATCH /cards/:id`. - /// Lädt für Image-Occlusion / Audio-Front bei Bedarf neue Media - /// hoch; sonst wird der bestehende `*_ref` aus der Card weiterverwendet. - static func build(inputs: CardEditorPayloadInputs, api: WordeckAPI) async throws -> CardEditorPayload { - switch inputs.type { - case .basic, .basicReverse: - CardEditorPayload( - fields: CardFieldsBuilder.basic(front: inputs.front, back: inputs.back), - mediaRefs: nil - ) - case .cloze: - CardEditorPayload( - fields: CardFieldsBuilder.cloze(text: inputs.clozeText), - mediaRefs: nil - ) - case .typing: - CardEditorPayload( - fields: CardFieldsBuilder.typing(front: inputs.front, answer: inputs.typingAnswer), - mediaRefs: nil - ) - case .multipleChoice: - CardEditorPayload( - fields: CardFieldsBuilder.multipleChoice( - front: inputs.front, - answer: inputs.multipleChoiceAnswer - ), - mediaRefs: nil - ) - case .imageOcclusion: - try await buildImageOcclusionPayload(inputs: inputs, api: api) - case .audioFront: - try await buildAudioFrontPayload(inputs: inputs, api: api) - } - } - - private static func buildImageOcclusionPayload( - inputs: CardEditorPayloadInputs, - api: WordeckAPI - ) async throws -> CardEditorPayload { - let imageRef: String - var refs = inputs.existingMediaRefs - - if let newData = inputs.occlusionImageData { - let media = try await api.uploadMedia( - data: newData, - filename: "occlusion.\(inputs.occlusionMimeType.contains("png") ? "png" : "jpg")", - mimeType: inputs.occlusionMimeType - ) - imageRef = media.id - refs = [media.id] - } else if let ref = inputs.existingImageRef { - imageRef = ref - } else { - throw CardEditorPayloadError.missingImage - } - - return CardEditorPayload( - fields: CardFieldsBuilder.imageOcclusion( - imageRef: imageRef, - regions: inputs.occlusionRegions, - note: inputs.occlusionNote.isEmpty ? nil : inputs.occlusionNote - ), - mediaRefs: refs - ) - } - - private static func buildAudioFrontPayload( - inputs: CardEditorPayloadInputs, - api: WordeckAPI - ) async throws -> CardEditorPayload { - let audioRef: String - var refs = inputs.existingMediaRefs - - if let url = inputs.audioFileURL { - let didStart = url.startAccessingSecurityScopedResource() - defer { if didStart { url.stopAccessingSecurityScopedResource() } } - let data = try Data(contentsOf: url) - let media = try await api.uploadMedia( - data: data, - filename: url.lastPathComponent, - mimeType: audioMimeType(for: url) - ) - audioRef = media.id - refs = [media.id] - } else if let ref = inputs.existingAudioRef { - audioRef = ref - } else { - throw CardEditorPayloadError.missingAudio - } - - return CardEditorPayload( - fields: CardFieldsBuilder.audioFront(audioRef: audioRef, back: inputs.back), - mediaRefs: refs - ) - } - - private static func audioMimeType(for url: URL) -> String { - switch url.pathExtension.lowercased() { - case "mp3": "audio/mpeg" - case "wav": "audio/wav" - case "m4a", "mp4": "audio/mp4" - case "ogg", "oga": "audio/ogg" - default: "audio/mpeg" - } - } -} diff --git a/Sources/Features/Editor/CardEditorView.swift b/Sources/Features/Editor/CardEditorView.swift index 4132bcb..4fcbdbe 100644 --- a/Sources/Features/Editor/CardEditorView.swift +++ b/Sources/Features/Editor/CardEditorView.swift @@ -1,20 +1,11 @@ import ManaCore import SwiftUI -#if canImport(UIKit) - import UIKit -#endif - -// swiftlint:disable type_body_length - /// Card-Create und Card-Edit in einer View. /// /// - `.create(deckId:)` zeigt Type-Picker + leere Felder. /// - `.edit(card:)` blendet Type-Picker aus (Server-seitig immutable), /// pre-fillt alle Felder, und PATCHt auf Submit. -/// -/// Bei Image-Occlusion und Audio-Front im Edit-Modus bleibt der bestehende -/// Media-Ref erhalten, solange der User die Datei nicht explizit ersetzt. struct CardEditorView: View { enum Mode { case create(deckId: String) @@ -36,24 +27,8 @@ struct CardEditorView: View { @State private var isSubmitting = false @State private var errorMessage: String? - // Image-Occlusion-State - @State private var occlusionImage: PlatformImage? - @State private var occlusionImageData: Data? - @State private var occlusionMimeType: String = "image/jpeg" - @State private var occlusionRegions: [MaskRegion] - @State private var occlusionNote: String - /// Bestehender `image_ref` aus der Card im Edit-Modus. Bleibt erhalten, - /// solange der User kein neues Bild wählt. - @State private var existingImageRef: String? - - /// Audio-Front-State - @State private var audioFileURL: URL? - /// Bestehender `audio_ref` aus der Card im Edit-Modus. - @State private var existingAudioRef: String? - private static let supportedTypes: [CardType] = [ - .basic, .basicReverse, .cloze, .typing, .multipleChoice, - .imageOcclusion, .audioFront + .basic, .basicReverse, .cloze, .typing, .multipleChoice ] init(mode: Mode, onSaved: @escaping (Card) -> Void) { @@ -66,10 +41,6 @@ struct CardEditorView: View { var initialCloze = "" var initialTyping = "" var initialMC = "" - var initialRegions: [MaskRegion] = [] - var initialNote = "" - var initialImageRef: String? - var initialAudioRef: String? switch mode { case .create: @@ -88,13 +59,6 @@ struct CardEditorView: View { case .multipleChoice: initialFront = card.fields["front"] ?? "" initialMC = card.fields["answer"] ?? "" - case .imageOcclusion: - initialRegions = MaskRegions.parse(card.fields["mask_regions"] ?? "[]") - initialNote = card.fields["note"] ?? "" - initialImageRef = card.fields["image_ref"] - case .audioFront: - initialBack = card.fields["back"] ?? "" - initialAudioRef = card.fields["audio_ref"] } } @@ -104,10 +68,6 @@ struct CardEditorView: View { _clozeText = State(initialValue: initialCloze) _typingAnswer = State(initialValue: initialTyping) _multipleChoiceAnswer = State(initialValue: initialMC) - _occlusionRegions = State(initialValue: initialRegions) - _occlusionNote = State(initialValue: initialNote) - _existingImageRef = State(initialValue: initialImageRef) - _existingAudioRef = State(initialValue: initialAudioRef) } var body: some View { @@ -221,24 +181,6 @@ struct CardEditorView: View { .font(.caption) .foregroundStyle(WordeckTheme.mutedForeground) } - - case .imageOcclusion: - ImageOcclusionFields( - image: $occlusionImage, - imageData: $occlusionImageData, - mimeType: $occlusionMimeType, - regions: $occlusionRegions, - note: $occlusionNote, - existingImageRef: $existingImageRef, - onLoadError: { errorMessage = $0 } - ) - - case .audioFront: - AudioFrontFields( - audioFileURL: $audioFileURL, - back: $back, - existingAudioRef: existingAudioRef - ) } } @@ -247,18 +189,6 @@ struct CardEditorView: View { return false } - private var deckId: String { - switch mode { - case let .create(deckId): deckId - case let .edit(card): card.deckId - } - } - - private var existingMediaRefs: [String] { - if case let .edit(card) = mode { return card.mediaRefs } - return [] - } - private var canSubmit: Bool { switch type { case .basic, .basicReverse: @@ -269,36 +199,42 @@ struct CardEditorView: View { !front.trimmed.isEmpty && !typingAnswer.trimmed.isEmpty case .multipleChoice: !front.trimmed.isEmpty && !multipleChoiceAnswer.trimmed.isEmpty - case .imageOcclusion: - (occlusionImageData != nil || existingImageRef != nil) && !occlusionRegions.isEmpty - case .audioFront: - (audioFileURL != nil || existingAudioRef != nil) && !back.trimmed.isEmpty } } - // MARK: - Submit + private func buildFields() -> [String: String] { + switch type { + case .basic, .basicReverse: + CardFieldsBuilder.basic(front: front.trimmed, back: back.trimmed) + case .cloze: + CardFieldsBuilder.cloze(text: clozeText.trimmed) + case .typing: + CardFieldsBuilder.typing(front: front.trimmed, answer: typingAnswer.trimmed) + case .multipleChoice: + CardFieldsBuilder.multipleChoice( + front: front.trimmed, + answer: multipleChoiceAnswer.trimmed + ) + } + } private func submit() async { isSubmitting = true errorMessage = nil defer { isSubmitting = false } let api = WordeckAPI(auth: auth) + let fields = buildFields() do { - let payload = try await CardEditorPayloadBuilder.build(inputs: payloadInputs, api: api) let card: Card = switch mode { case let .create(deckId): try await api.createCard(CardCreateBody( deckId: deckId, type: type, - fields: payload.fields, - mediaRefs: payload.mediaRefs + fields: fields )) case let .edit(existing): - try await api.updateCard(id: existing.id, body: CardUpdateBody( - fields: payload.fields, - mediaRefs: payload.mediaRefs - )) + try await api.updateCard(id: existing.id, body: CardUpdateBody(fields: fields)) } onSaved(card) dismiss() @@ -307,25 +243,6 @@ struct CardEditorView: View { } } - private var payloadInputs: CardEditorPayloadInputs { - CardEditorPayloadInputs( - type: type, - front: front.trimmed, - back: back.trimmed, - clozeText: clozeText.trimmed, - typingAnswer: typingAnswer.trimmed, - multipleChoiceAnswer: multipleChoiceAnswer.trimmed, - occlusionImageData: occlusionImageData, - occlusionMimeType: occlusionMimeType, - occlusionRegions: occlusionRegions, - occlusionNote: occlusionNote.trimmed, - existingImageRef: existingImageRef, - audioFileURL: audioFileURL, - existingAudioRef: existingAudioRef, - existingMediaRefs: existingMediaRefs - ) - } - private func label(for type: CardType) -> String { switch type { case .basic: "Einfach (Vorder/Rück)" @@ -333,14 +250,10 @@ struct CardEditorView: View { case .cloze: "Lückentext" case .typing: "Eintippen" case .multipleChoice: "Multiple Choice" - case .imageOcclusion: "Bild-Verdeckung" - case .audioFront: "Audio" } } } -// swiftlint:enable type_body_length - private extension String { var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) diff --git a/Sources/Features/Editor/DeckEditorHelpers.swift b/Sources/Features/Editor/DeckEditorHelpers.swift index f3123d4..e160927 100644 --- a/Sources/Features/Editor/DeckEditorHelpers.swift +++ b/Sources/Features/Editor/DeckEditorHelpers.swift @@ -1,12 +1,11 @@ import Foundation import ManaCore -/// Konstanten für `DeckEditorView` — Farbpalette, File-Limits. -/// Werte gespiegelt aus `forest`-Theme und Server-Limits in -/// `cards/apps/api/src/routes/decks-from-image.ts`. +/// Konstanten für `DeckEditorView` — Farbpalette. +/// Werte gespiegelt aus dem `forest`-Theme. enum DeckEditorPresets { /// 8 Farb-Presets aus dem forest-Theme. Freie Hex-Werte später - /// via Custom-Picker (β-3-extension). + /// via Custom-Picker. static let colors: [String] = [ "#10803D", // forest primary light "#1E3A2F", // forest dark @@ -17,10 +16,6 @@ enum DeckEditorPresets { "#0D9488", // teal "#737373" // neutral ] - - static let maxMediaFiles = 5 - static let maxImageBytes = 10 * 1024 * 1024 - static let maxPDFBytes = 30 * 1024 * 1024 } /// Reine Hilfsfunktionen für `DeckEditorView` — kein State, keine Bindings. @@ -39,29 +34,7 @@ enum DeckEditorHelpers { return scheme == "http" || scheme == "https" } - /// Magic-Byte-Check für die häufigsten Image-Formate. Fallback JPEG. - static func inferImageMimeType(from data: Data) -> String { - guard data.count > 4 else { return "image/jpeg" } - let bytes = Array(data.prefix(8)) - if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { return "image/jpeg" } - if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47]) { return "image/png" } - if bytes.starts(with: [0x47, 0x49, 0x46, 0x38]) { return "image/gif" } - if bytes.count >= 4, bytes[0 ... 3] == [0x52, 0x49, 0x46, 0x46] { return "image/webp" } - return "image/jpeg" - } - - /// Dateiendung für ein erkanntes Image-MIME. - static func imageExtension(forMime mime: String) -> String { - switch mime { - case "image/png": "png" - case "image/gif": "gif" - case "image/webp": "webp" - default: "jpg" - } - } - /// AuthError-Server-Codes auf nutzerfreundliche deutsche Texte mappen. - /// Greift für beide AI-Endpoints, fällt sonst auf `errorDescription`. static func mapAIError(_ error: AuthError) -> String { if case let .serverError(status, _, message) = error { switch status { diff --git a/Sources/Features/Editor/DeckEditorView.swift b/Sources/Features/Editor/DeckEditorView.swift index aa8f9f1..1ae7e70 100644 --- a/Sources/Features/Editor/DeckEditorView.swift +++ b/Sources/Features/Editor/DeckEditorView.swift @@ -1,28 +1,24 @@ import ManaCore -import PhotosUI import SwiftUI // swiftlint:disable file_length // swiftlint:disable type_body_length -/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen vier -/// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI"), AI-Vision -/// („Aus Bild") und CSV. Edit-Modus zeigt nur das manuelle Formular. +/// Deck-Create und Deck-Edit in einer View. Im Create-Modus stehen drei +/// Sub-Modi zur Wahl: manuell („Leer"), AI-Text („Mit KI") und CSV. +/// Edit-Modus zeigt nur das manuelle Formular. /// -/// Web-Vorbild: `cards/apps/web/src/routes/decks/new/+page.svelte`. -/// `type_body_length` ist bewusst übersprungen — die 4 Sub-Modi teilen -/// sich State + Toolbar; aufspalten ginge nur über @Binding-Plumbing. +/// Web-Vorbild: `wordeck/apps/web/src/routes/decks/new/+page.svelte`. struct DeckEditorView: View { enum Mode { case create case edit(deckId: String) } - /// Vier Sub-Modi im Create-Sheet. + /// Drei Sub-Modi im Create-Sheet. enum CreateMode: Hashable { case manual case aiText - case aiMedia case csv } @@ -43,17 +39,12 @@ struct DeckEditorView: View { /// Create-mode selector @State private var createMode: CreateMode = .manual - // AI-shared (Text + Media) + // AI-Text @State private var aiPrompt: String = "" @State private var aiCount: Int = 15 @State private var aiLanguage: GenerationLanguage = .de @State private var aiUrl: String = "" - // AI-Media - @State private var aiMediaFiles: [GenerationMediaFile] = [] - @State private var aiPhotoItems: [PhotosPickerItem] = [] - @State private var showPDFImporter: Bool = false - // CSV-Import @State private var csvRows: [CSVRow] = [] @State private var csvDeckName: String = "" @@ -99,16 +90,6 @@ struct DeckEditorView: View { .navigationBarTitleDisplayMode(.inline) #endif .toolbar { toolbar } - .onChange(of: aiPhotoItems) { _, items in - guard !items.isEmpty else { return } - Task { await ingestPhotoItems(items) } - } - .fileImporter( - isPresented: $showPDFImporter, - allowedContentTypes: [.pdf], - allowsMultipleSelection: true, - onCompletion: handlePDFImport - ) .fileImporter( isPresented: $showCSVImporter, allowedContentTypes: [.commaSeparatedText, .plainText], @@ -124,7 +105,6 @@ struct DeckEditorView: View { Picker("Modus", selection: $createMode) { Text("Leer").tag(CreateMode.manual) Text("KI").tag(CreateMode.aiText) - Text("Bild").tag(CreateMode.aiMedia) Text("CSV").tag(CreateMode.csv) } .pickerStyle(.segmented) @@ -140,8 +120,6 @@ struct DeckEditorView: View { Text("Leeres Deck — Karten anschließend selbst anlegen.") case .aiText: Text("KI generiert das Deck aus einer kurzen Beschreibung. 10 Anfragen pro Minute.") - case .aiMedia: - Text("KI liest Bilder oder PDFs und macht daraus Karten. Bis zu 5 Dateien.") case .csv: Text("CSV-Datei einlesen. Format: vorne,hinten[,typ] pro Zeile.") } @@ -162,13 +140,6 @@ struct DeckEditorView: View { case .aiText: AITextFormSections(prompt: $aiPrompt) AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) - case .aiMedia: - AIMediaFormSections( - files: $aiMediaFiles, - photoItems: $aiPhotoItems, - showPDFImporter: $showPDFImporter - ) - AISharedSections(count: $aiCount, language: $aiLanguage, url: $aiUrl) case .csv: CSVImportFormSections( rows: $csvRows, @@ -222,7 +193,6 @@ struct DeckEditorView: View { switch activeMode { case .manual: isCreate ? "Neues Deck" : "Deck bearbeiten" case .aiText: "Mit KI generieren" - case .aiMedia: "Aus Bild generieren" case .csv: "Aus CSV importieren" } } @@ -230,7 +200,7 @@ struct DeckEditorView: View { private var confirmLabel: String { switch activeMode { case .manual: isCreate ? "Erstellen" : "Speichern" - case .aiText, .aiMedia: "Generieren" + case .aiText: "Generieren" case .csv: csvRows.isEmpty ? "Importieren" : "\(csvRows.count) Karten importieren" } } @@ -241,8 +211,6 @@ struct DeckEditorView: View { !name.trimmingCharacters(in: .whitespaces).isEmpty case .aiText: aiPrompt.trimmingCharacters(in: .whitespaces).count >= 3 - case .aiMedia: - !aiMediaFiles.isEmpty || DeckEditorHelpers.isValidURL(aiUrl) case .csv: !csvRows.isEmpty && !csvDeckName.trimmingCharacters(in: .whitespaces).isEmpty } @@ -259,31 +227,7 @@ struct DeckEditorView: View { } } - // MARK: - Photo / PDF ingest - - private func ingestPhotoItems(_ items: [PhotosPickerItem]) async { - for item in items { - if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break } - do { - guard let data = try await item.loadTransferable(type: Data.self) else { continue } - guard data.count <= DeckEditorPresets.maxImageBytes else { - errorMessage = "Bild ist größer als 10 MB und wurde übersprungen." - continue - } - let mime = DeckEditorHelpers.inferImageMimeType(from: data) - let ext = DeckEditorHelpers.imageExtension(forMime: mime) - let filename = "image-\(UUID().uuidString.prefix(8)).\(ext)" - aiMediaFiles.append(GenerationMediaFile( - data: data, - filename: filename, - mimeType: mime - )) - } catch { - errorMessage = "Foto konnte nicht geladen werden: \(error.localizedDescription)" - } - } - aiPhotoItems = [] - } + // MARK: - CSV ingest private func handleCSVImport(_ result: Result<[URL], Error>) { switch result { @@ -306,33 +250,6 @@ struct DeckEditorView: View { } } - private func handlePDFImport(_ result: Result<[URL], Error>) { - switch result { - case let .success(urls): - for url in urls { - if aiMediaFiles.count >= DeckEditorPresets.maxMediaFiles { break } - let didStart = url.startAccessingSecurityScopedResource() - defer { if didStart { url.stopAccessingSecurityScopedResource() } } - do { - let data = try Data(contentsOf: url) - guard data.count <= DeckEditorPresets.maxPDFBytes else { - errorMessage = "\(url.lastPathComponent) ist größer als 30 MB." - continue - } - aiMediaFiles.append(GenerationMediaFile( - data: data, - filename: url.lastPathComponent, - mimeType: "application/pdf" - )) - } catch { - errorMessage = "PDF konnte nicht gelesen werden: \(error.localizedDescription)" - } - } - case let .failure(error): - errorMessage = "PDF-Auswahl fehlgeschlagen: \(error.localizedDescription)" - } - } - // MARK: - Submit private func startSubmit() { @@ -362,16 +279,6 @@ struct DeckEditorView: View { try Task.checkCancellation() onSaved(response.deck) dismiss() - case (.create, .aiMedia): - let response = try await api.generateDeckFromMedia( - files: aiMediaFiles, - language: aiLanguage, - count: aiCount, - url: DeckEditorHelpers.nonEmpty(aiUrl) - ) - try Task.checkCancellation() - onSaved(response.deck) - dismiss() case (.create, .csv): let deck = try await submitCSVImport(api: api) onSaved(deck) @@ -418,26 +325,20 @@ struct DeckEditorView: View { csvImportProgress = 0 for (index, row) in csvRows.enumerated() { try Task.checkCancellation() - let fields: [String: String] - switch row.type { + let fields: [String: String] = switch row.type { case .basic, .basicReverse: - fields = CardFieldsBuilder.basic(front: row.front, back: row.back) + CardFieldsBuilder.basic(front: row.front, back: row.back) case .cloze: - fields = CardFieldsBuilder.cloze(text: row.front) + CardFieldsBuilder.cloze(text: row.front) case .typing: - fields = CardFieldsBuilder.typing(front: row.front, answer: row.back) + CardFieldsBuilder.typing(front: row.front, answer: row.back) case .multipleChoice: - fields = CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back) - case .imageOcclusion, .audioFront: - // Media-Types brauchen Uploads — überspringe in CSV-Import. - csvImportProgress = index + 1 - continue + CardFieldsBuilder.multipleChoice(front: row.front, answer: row.back) } _ = try await api.createCard(CardCreateBody( deckId: deck.id, type: row.type, - fields: fields, - mediaRefs: nil + fields: fields )) csvImportProgress = index + 1 } @@ -558,101 +459,6 @@ private struct AITextFormSections: View { } } -// MARK: - AI media form - -private struct AIMediaFormSections: View { - @Binding var files: [GenerationMediaFile] - @Binding var photoItems: [PhotosPickerItem] - @Binding var showPDFImporter: Bool - - var body: some View { - Section { - mediaPickers - ForEach(files) { file in - MediaFileRow(file: file) { - files.removeAll { $0.id == file.id } - } - } - } header: { - Text("Quellen") - } footer: { - Text("Max. \(DeckEditorPresets.maxMediaFiles) Dateien. Bilder ≤ 10 MB, PDFs ≤ 30 MB.") - } - } - - @ViewBuilder - private var mediaPickers: some View { - let remaining = DeckEditorPresets.maxMediaFiles - files.count - - PhotosPicker( - selection: $photoItems, - maxSelectionCount: max(remaining, 0), - matching: .images - ) { - Label("Fotos hinzufügen", systemImage: "photo.on.rectangle.angled") - } - .disabled(remaining <= 0) - - Button { - showPDFImporter = true - } label: { - Label("PDFs hinzufügen", systemImage: "doc.text") - } - .disabled(remaining <= 0) - } -} - -private struct MediaFileRow: View { - let file: GenerationMediaFile - let onRemove: () -> Void - - var body: some View { - HStack(spacing: 12) { - thumbnail - .frame(width: 40, height: 40) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) - VStack(alignment: .leading, spacing: 2) { - Text(file.filename) - .font(.subheadline) - .lineLimit(1) - Text(file.sizeLabel) - .font(.caption) - .foregroundStyle(WordeckTheme.mutedForeground) - } - Spacer() - Button(action: onRemove) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(WordeckTheme.mutedForeground) - } - .buttonStyle(.plain) - .accessibilityLabel("Entfernen") - } - } - - @ViewBuilder - private var thumbnail: some View { - if file.isPDF { - ZStack { - WordeckTheme.muted - Image(systemName: "doc.text.fill") - .foregroundStyle(WordeckTheme.primary) - } - } else if let img = PlatformImage(data: file.data) { - #if canImport(UIKit) - Image(uiImage: img) - .resizable() - .scaledToFill() - #else - Image(nsImage: img) - .resizable() - .scaledToFill() - #endif - } else { - WordeckTheme.muted - } - } -} - // MARK: - Shared AI controls private struct AISharedSections: View { diff --git a/Sources/Features/Editor/MaskEditorView.swift b/Sources/Features/Editor/MaskEditorView.swift deleted file mode 100644 index 2aad021..0000000 --- a/Sources/Features/Editor/MaskEditorView.swift +++ /dev/null @@ -1,146 +0,0 @@ -import SwiftUI - -#if canImport(UIKit) - import UIKit -#endif - -/// Mask-Editor: Bild anzeigen, mit Drag-Gesten Rechtecke zeichnen, jede -/// Region mit Label versehen. Coordinaten 0..1 relativ zur Bild-Größe. -/// -/// Output binding ist `regions`. Caller serialisiert via `MaskRegions.encode()`. -struct MaskEditorView: View { - let image: PlatformImage - @Binding var regions: [MaskRegion] - - @State private var dragStart: CGPoint? - @State private var dragEnd: CGPoint? - @State private var nextIdCounter: Int = 0 - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Tippe und ziehe auf das Bild, um eine Maske zu erstellen.") - .font(.caption) - .foregroundStyle(WordeckTheme.mutedForeground) - - imageCanvas - .aspectRatio(image.size.width / max(image.size.height, 1), contentMode: .fit) - .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: 8)) - - if regions.isEmpty { - Text("Noch keine Maske") - .font(.caption) - .foregroundStyle(WordeckTheme.mutedForeground) - } else { - ForEach(regions) { region in - maskRow(region: region) - } - } - } - } - - private var imageCanvas: some View { - GeometryReader { geo in - ZStack(alignment: .topLeading) { - #if canImport(UIKit) - Image(uiImage: image).resizable().aspectRatio(contentMode: .fit) - #else - Image(nsImage: image).resizable().aspectRatio(contentMode: .fit) - #endif - - ForEach(regions) { region in - overlayRect(for: region, in: geo.size) - } - - if let dragStart, let dragEnd { - let rect = normalizedRect(from: dragStart, to: dragEnd) - Rectangle() - .stroke(WordeckTheme.warning, lineWidth: 2) - .background(Rectangle().fill(WordeckTheme.warning.opacity(0.2))) - .frame(width: rect.width, height: rect.height) - .offset(x: rect.minX, y: rect.minY) - } - } - .contentShape(Rectangle()) - .gesture( - DragGesture(minimumDistance: 4) - .onChanged { value in - if dragStart == nil { dragStart = value.startLocation } - dragEnd = value.location - } - .onEnded { value in - commitDrag(start: value.startLocation, end: value.location, in: geo.size) - } - ) - } - } - - private func overlayRect(for region: MaskRegion, in size: CGSize) -> some View { - Rectangle() - .fill(WordeckTheme.primary.opacity(0.6)) - .frame(width: region.w * size.width, height: region.h * size.height) - .offset(x: region.x * size.width, y: region.y * size.height) - .overlay( - Text(region.label?.isEmpty == false ? region.label! : region.id) - .font(.caption2.weight(.bold)) - .foregroundStyle(WordeckTheme.primaryForeground) - .padding(2) - .offset(x: region.x * size.width + 2, y: region.y * size.height + 2), - alignment: .topLeading - ) - } - - private func maskRow(region: MaskRegion) -> some View { - HStack(spacing: 8) { - Image(systemName: "square.dashed") - .foregroundStyle(WordeckTheme.primary) - TextField("Label (optional)", text: Binding( - get: { region.label ?? "" }, - set: { newValue in updateLabel(for: region.id, to: newValue) } - )) - .textFieldStyle(.roundedBorder) - Button(role: .destructive) { - regions.removeAll { $0.id == region.id } - } label: { - Image(systemName: "trash") - .foregroundStyle(WordeckTheme.error) - } - .buttonStyle(.plain) - } - } - - private func updateLabel(for id: String, to value: String) { - guard let idx = regions.firstIndex(where: { $0.id == id }) else { return } - let old = regions[idx] - regions[idx] = MaskRegion(id: old.id, x: old.x, y: old.y, w: old.w, h: old.h, label: value) - } - - private func normalizedRect(from start: CGPoint, to end: CGPoint) -> CGRect { - let x = min(start.x, end.x) - let y = min(start.y, end.y) - let w = abs(end.x - start.x) - let h = abs(end.y - start.y) - return CGRect(x: x, y: y, width: w, height: h) - } - - private func commitDrag(start: CGPoint, end: CGPoint, in size: CGSize) { - defer { - dragStart = nil - dragEnd = nil - } - let rect = normalizedRect(from: start, to: end) - // Mindestgröße 1% der Bildkante — Tap-Klicks ignorieren - guard rect.width > size.width * 0.01, rect.height > size.height * 0.01 else { return } - nextIdCounter += 1 - let id = String(format: "m%03d", nextIdCounter) - let normalized = MaskRegion( - id: id, - x: rect.minX / size.width, - y: rect.minY / size.height, - w: rect.width / size.width, - h: rect.height / size.height, - label: nil - ) - regions.append(normalized) - } -} diff --git a/Sources/Features/Marketplace/ExploreView.swift b/Sources/Features/Marketplace/ExploreView.swift index c174564..da72ea6 100644 --- a/Sources/Features/Marketplace/ExploreView.swift +++ b/Sources/Features/Marketplace/ExploreView.swift @@ -23,31 +23,31 @@ struct ExploreView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif - .navigationDestination(for: MarketplaceRoute.self) { route in - switch route { - case .browse: - BrowseView() - case let .publicDeck(slug): - PublicDeckView(slug: slug) + .navigationDestination(for: MarketplaceRoute.self) { route in + switch route { + case .browse: + BrowseView() + case let .publicDeck(slug): + PublicDeckView(slug: slug) + } } - } - .navigationDestination(for: String.self) { deckId in - DeckDetailView(deckId: deckId) - } - .refreshable { - await store?.loadExplore() - } - .task { - if store == nil { - store = MarketplaceStore(auth: auth) + .navigationDestination(for: String.self) { deckId in + DeckDetailView(deckId: deckId) + } + .refreshable { + await store?.loadExplore() + } + .task { + if store == nil { + store = MarketplaceStore(auth: auth) + } + await store?.loadExplore() + } + .onChange(of: deepLinkSlug) { _, newSlug in + guard let slug = newSlug else { return } + path = [.publicDeck(slug: slug)] + deepLinkSlug = nil } - await store?.loadExplore() - } - .onChange(of: deepLinkSlug) { _, newSlug in - guard let slug = newSlug else { return } - path = [.publicDeck(slug: slug)] - deepLinkSlug = nil - } } } diff --git a/Sources/Features/Media/AudioPlayerButton.swift b/Sources/Features/Media/AudioPlayerButton.swift deleted file mode 100644 index 1aec08b..0000000 --- a/Sources/Features/Media/AudioPlayerButton.swift +++ /dev/null @@ -1,75 +0,0 @@ -import AVFoundation -import SwiftUI - -/// Audio-Wiedergabe-Button für `audio-front`-Karten. Lädt das File einmal -/// per MediaCache, spielt mit AVAudioPlayer ab. -struct AudioPlayerButton: View { - let mediaId: String - - @Environment(\.mediaCache) private var mediaCache - @State private var player: AVAudioPlayer? - @State private var isPlaying = false - @State private var failed = false - - var body: some View { - Button { - togglePlayback() - } label: { - HStack(spacing: 12) { - Image(systemName: failed - ? "speaker.slash.fill" - : (isPlaying ? "pause.circle.fill" : "play.circle.fill")) - .font(.system(size: 48)) - .foregroundStyle(failed ? WordeckTheme.error : WordeckTheme.primary) - Text(failed ? "Audio nicht verfügbar" : (isPlaying ? "Wiedergabe läuft" : "Anhören")) - .font(.headline) - .foregroundStyle(WordeckTheme.foreground) - } - .frame(maxWidth: .infinity) - .padding(20) - .background(WordeckTheme.surface, in: RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(WordeckTheme.border, lineWidth: 1) - ) - } - .buttonStyle(.plain) - .disabled(failed) - .task(id: mediaId) { - await load() - } - .onDisappear { - player?.stop() - isPlaying = false - } - } - - private func load() async { - guard let cache = mediaCache else { failed = true - return - } - do { - let data = try await cache.data(for: mediaId) - #if canImport(UIKit) - try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default) - try AVAudioSession.sharedInstance().setActive(true) - #endif - player = try AVAudioPlayer(data: data) - player?.prepareToPlay() - } catch { - failed = true - } - } - - private func togglePlayback() { - guard let player else { return } - if player.isPlaying { - player.pause() - isPlaying = false - } else { - player.currentTime = 0 - player.play() - isPlaying = true - } - } -} diff --git a/Sources/Features/Media/RemoteImage.swift b/Sources/Features/Media/RemoteImage.swift deleted file mode 100644 index 52fd790..0000000 --- a/Sources/Features/Media/RemoteImage.swift +++ /dev/null @@ -1,72 +0,0 @@ -import SwiftUI - -#if canImport(UIKit) - import UIKit -#elseif canImport(AppKit) - import AppKit -#endif - -/// Lädt ein authentifiziertes Image vom Wordeck-Media-Endpoint und -/// rendert es. Streamt erst beim ersten Mal, danach aus dem -/// MediaCache (LRU 200 MB). -struct RemoteImage: View { - let mediaId: String - let contentMode: ContentMode - - @Environment(\.mediaCache) private var mediaCache - @State private var image: PlatformImage? - @State private var failed = false - - init(mediaId: String, contentMode: ContentMode = .fit) { - self.mediaId = mediaId - self.contentMode = contentMode - } - - var body: some View { - Group { - if let image { - imageView(image) - } else if failed { - ContentUnavailableView("Bild konnte nicht geladen werden", systemImage: "photo.badge.exclamationmark") - .foregroundStyle(WordeckTheme.mutedForeground) - } else { - ProgressView() - .tint(WordeckTheme.primary) - } - } - .task(id: mediaId) { - await load() - } - } - - @ViewBuilder - private func imageView(_ image: PlatformImage) -> some View { - #if canImport(UIKit) - Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode) - #elseif canImport(AppKit) - Image(nsImage: image).resizable().aspectRatio(contentMode: contentMode) - #endif - } - - private func load() async { - guard let cache = mediaCache else { failed = true - return - } - do { - let data = try await cache.data(for: mediaId) - if let img = PlatformImage(data: data) { - image = img - } else { - failed = true - } - } catch { - failed = true - } - } -} - -#if canImport(UIKit) - typealias PlatformImage = UIImage -#elseif canImport(AppKit) - typealias PlatformImage = NSImage -#endif diff --git a/Sources/Features/Study/CardRenderer.swift b/Sources/Features/Study/CardRenderer.swift index 14f0d1b..d67445a 100644 --- a/Sources/Features/Study/CardRenderer.swift +++ b/Sources/Features/Study/CardRenderer.swift @@ -1,10 +1,8 @@ import SwiftUI /// Rendert die Karten-Inhalte je nach `CardType`. Front-/Back-Seite -/// werden über `isFlipped` gesteuert. -/// -/// β-2 deckt `basic`, `basic-reverse`, `cloze` ab. Restliche Typen -/// zeigen einen Placeholder mit Hinweis auf die kommende Phase. +/// werden über `isFlipped` gesteuert. Wordeck ist text-only — alle +/// Card-Types rendern ausschließlich Markdown-Text. struct CardRenderer: View { let card: ReviewCard let subIndex: Int @@ -24,10 +22,6 @@ struct CardRenderer: View { } case .cloze: clozeView - case .imageOcclusion: - imageOcclusionView - case .audioFront: - audioFrontView case .multipleChoice: MultipleChoiceCardView(card: card, isFlipped: isFlipped) case .typing: @@ -66,82 +60,6 @@ struct CardRenderer: View { } } - @ViewBuilder - private var imageOcclusionView: some View { - let imageRef = card.fields["image_ref"] ?? "" - let maskJSON = card.fields["mask_regions"] ?? "[]" - let regions = MaskRegions.parse(maskJSON) - let activeRegion = regions.indices.contains(subIndex) ? regions[subIndex] : nil - - VStack(spacing: 12) { - GeometryReader { geo in - ZStack(alignment: .topLeading) { - RemoteImage(mediaId: imageRef, contentMode: .fit) - .frame(width: geo.size.width, height: geo.size.height) - ForEach(regions) { region in - let isActive = region.id == activeRegion?.id - // Front: aktive Maske opak, andere transparent. - // Back: alle Masken transparent (Bild komplett sichtbar). - if !isFlipped, isActive { - Rectangle() - .fill(WordeckTheme.primary.opacity(0.92)) - .frame( - width: region.w * geo.size.width, - height: region.h * geo.size.height - ) - .offset(x: region.x * geo.size.width, y: region.y * geo.size.height) - .overlay( - Text(region.label?.isEmpty == false ? region.label! : "?") - .font(.caption.weight(.bold)) - .foregroundStyle(WordeckTheme.primaryForeground) - .offset(x: region.x * geo.size.width, y: region.y * geo.size.height), - alignment: .topLeading - ) - } - } - } - } - .aspectRatio(4 / 3, contentMode: .fit) - - if isFlipped, let label = activeRegion?.label, !label.isEmpty { - Text(label) - .font(.title3.weight(.semibold)) - .foregroundStyle(WordeckTheme.primary) - } - if let note = card.fields["note"], !note.isEmpty { - Text(note) - .font(.caption) - .foregroundStyle(WordeckTheme.mutedForeground) - } - } - } - - @ViewBuilder - private var audioFrontView: some View { - let audioRef = card.fields["audio_ref"] ?? "" - VStack(spacing: 16) { - AudioPlayerButton(mediaId: audioRef) - if isFlipped { - Divider().background(WordeckTheme.border) - text(card.fields["back"] ?? "") - .font(.title3) - .foregroundStyle(WordeckTheme.foreground) - } - } - } - - private var placeholderView: some View { - VStack(spacing: 8) { - Image(systemName: "questionmark.square.dashed") - .font(.largeTitle) - .foregroundStyle(WordeckTheme.mutedForeground) - Text("Card-Type »\(card.type.rawValue)« kommt in einer späteren Phase") - .font(.caption) - .multilineTextAlignment(.center) - .foregroundStyle(WordeckTheme.mutedForeground) - } - } - /// Markdown-Bold (`**...**`) wird auf SwiftUI's AttributedString gemappt. private func text(_ markdown: String) -> some View { let attributed = (try? AttributedString( diff --git a/Sources/Features/Study/StudySession.swift b/Sources/Features/Study/StudySession.swift index bacbb6a..8ab8338 100644 --- a/Sources/Features/Study/StudySession.swift +++ b/Sources/Features/Study/StudySession.swift @@ -5,6 +5,11 @@ import SwiftData /// State-Machine für eine Lern-Session. Lädt Due-Reviews beim Start, /// rendert eine Karte nach der anderen, schickt Grades via GradeQueue ab. +/// +/// Seit ζ-1 (2026-05-18): wenn der Server-Call scheitert, fällt die +/// Session auf den `CachedDueReview`-Snapshot vom letzten Sync zurück. +/// Der User lernt dann offline. Grades laufen wie immer in die +/// `GradeQueue` und drainen beim Reconnect. @MainActor @Observable final class StudySession { @@ -20,16 +25,21 @@ final class StudySession { private(set) var currentIndex: Int = 0 private(set) var isFlipped: Bool = false private(set) var totalGraded: Int = 0 + /// `true` wenn die Session aus dem lokalen Snapshot statt vom Server + /// gestartet wurde. View kann ein Offline-Banner zeigen. + private(set) var isOfflineSession: Bool = false let deckId: String let deckName: String private let api: WordeckAPI + private let context: ModelContext private let gradeQueue: GradeQueue init(deckId: String, deckName: String, auth: AuthClient, context: ModelContext) { self.deckId = deckId self.deckName = deckName + self.context = context api = WordeckAPI(auth: auth) gradeQueue = GradeQueue(api: api, context: context) } @@ -50,6 +60,7 @@ final class StudySession { currentIndex = 0 isFlipped = false totalGraded = 0 + isOfflineSession = false if queue.isEmpty { phase = .finished } else { @@ -59,12 +70,37 @@ final class StudySession { let id = deckId Log.study.info("Session start — \(count, privacy: .public) due in deck \(id, privacy: .public)") } catch { - let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) - phase = .failed(msg) - Log.study.error("Session start failed: \(msg, privacy: .public)") + // Server nicht erreichbar oder Auth-Fehler → Cache-Fallback. + queue = loadFromCache() + currentIndex = 0 + isFlipped = false + totalGraded = 0 + if queue.isEmpty { + let msg = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + phase = .failed(msg) + Log.study.error("Session start failed (no cache): \(msg, privacy: .public)") + } else { + isOfflineSession = true + phase = .studying + let count = queue.count + let id = deckId + Log.study + .notice("Offline-Session — \(count, privacy: .public) cached due in deck \(id, privacy: .public)") + } } } + private func loadFromCache() -> [DueReview] { + let deckId = deckId + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.deckId == deckId }, + sortBy: [SortDescriptor(\.due, order: .forward)] + ) + descriptor.fetchLimit = 500 + let cached = (try? context.fetch(descriptor)) ?? [] + return cached.compactMap { $0.toDueReview() } + } + func flip() { guard case .studying = phase else { return } isFlipped.toggle() diff --git a/Sources/Features/Study/StudySessionView.swift b/Sources/Features/Study/StudySessionView.swift index 169a12a..6f1b332 100644 --- a/Sources/Features/Study/StudySessionView.swift +++ b/Sources/Features/Study/StudySessionView.swift @@ -66,6 +66,9 @@ struct StudySessionView: View { private func studyingView(session: StudySession) -> some View { VStack(spacing: 16) { + if session.isOfflineSession { + offlineBanner + } if let due = session.current { cardSurface(due: due, isFlipped: session.isFlipped) .onTapGesture { @@ -81,6 +84,24 @@ struct StudySessionView: View { .animation(.easeInOut(duration: 0.2), value: session.currentIndex) } + /// Banner für Offline-Sessions. Erklärt dem User ehrlich, dass er + /// gerade die Karten lernt, die zum letzten Sync fällig waren — + /// neue Karten kommen erst nach Wiederverbindung. + private var offlineBanner: some View { + HStack(spacing: 8) { + Image(systemName: "wifi.slash") + Text("Offline — Karten vom letzten Sync") + } + .font(.caption.weight(.medium)) + .foregroundStyle(WordeckTheme.mutedForeground) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(WordeckTheme.muted, in: Capsule()) + .padding(.horizontal, 16) + .padding(.top, 4) + .transition(.opacity) + } + /// Fixe Höhe, damit der Wechsel zwischen "Antwort anzeigen" und /// `RatingBar` die Card oben nicht stauchen kann — sonst proportioniert /// `.aspectRatio(.fit)` die Card neu und das Layout springt. @@ -137,6 +158,14 @@ struct StudySessionView: View { .font(.subheadline) .foregroundStyle(WordeckTheme.mutedForeground) } + if session.isOfflineSession { + Text("Weitere Karten erst nach Verbindung verfügbar.") + .font(.caption) + .multilineTextAlignment(.center) + .foregroundStyle(WordeckTheme.mutedForeground) + .padding(.horizontal, 32) + .padding(.top, 4) + } Button("Zurück") { dismiss() } .padding(.top, 24) } diff --git a/Tests/UnitTests/MutationEncodingTests.swift b/Tests/UnitTests/MutationEncodingTests.swift index 6dc26fb..34565ed 100644 --- a/Tests/UnitTests/MutationEncodingTests.swift +++ b/Tests/UnitTests/MutationEncodingTests.swift @@ -47,8 +47,7 @@ struct MutationEncodingTests { let body = CardCreateBody( deckId: "deck_1", type: .basic, - fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello"), - mediaRefs: nil + fields: CardFieldsBuilder.basic(front: "Hallo", back: "Hello") ) let json = try encode(body) #expect(json["deck_id"] as? String == "deck_1") @@ -64,8 +63,7 @@ struct MutationEncodingTests { let body = CardCreateBody( deckId: "d", type: .basicReverse, - fields: CardFieldsBuilder.basic(front: "a", back: "b"), - mediaRefs: nil + fields: CardFieldsBuilder.basic(front: "a", back: "b") ) let json = try encode(body) #expect(json["type"] as? String == "basic-reverse") @@ -76,8 +74,7 @@ struct MutationEncodingTests { let body = CardCreateBody( deckId: "d", type: .cloze, - fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint."), - mediaRefs: nil + fields: CardFieldsBuilder.cloze(text: "Die {{c1::Sonne}} scheint.") ) let json = try encode(body) #expect(json["type"] as? String == "cloze") @@ -90,8 +87,7 @@ struct MutationEncodingTests { let body = CardCreateBody( deckId: "d", type: .multipleChoice, - fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A"), - mediaRefs: nil + fields: CardFieldsBuilder.multipleChoice(front: "Q", answer: "A") ) let json = try encode(body) #expect(json["type"] as? String == "multiple-choice") @@ -99,7 +95,7 @@ struct MutationEncodingTests { @Test("CardUpdateBody nur mit fields") func cardUpdateBodyFieldsOnly() throws { - let body = CardUpdateBody(fields: ["front": "neu"], mediaRefs: nil) + let body = CardUpdateBody(fields: ["front": "neu"]) let json = try encode(body) #expect((json["fields"] as? [String: String])?["front"] == "neu") #expect(json["media_refs"] == nil) diff --git a/Tests/UnitTests/WordeckNativeTests.swift b/Tests/UnitTests/WordeckNativeTests.swift index 32d4b7b..a77a6c9 100644 --- a/Tests/UnitTests/WordeckNativeTests.swift +++ b/Tests/UnitTests/WordeckNativeTests.swift @@ -1,9 +1,10 @@ +import ManaCore import Testing @testable import WordeckNative @Suite("AppConfig") struct AppConfigTests { - @Test("Cards-API zeigt auf api.wordeck.com") + @Test("Wordeck-API zeigt auf api.wordeck.com") func apiBaseURLPointsToWordeck() { #expect(AppConfig.apiBaseURL.absoluteString == "https://api.wordeck.com") } @@ -13,8 +14,13 @@ struct AppConfigTests { #expect(AppConfig.manaAppConfig.authBaseURL.absoluteString == "https://auth.mana.how") } - @Test("Keychain-Service ist ev.mana.wordeck") - func keychainServiceIsAppSpecific() { - #expect(AppConfig.manaAppConfig.keychainService == "ev.mana.wordeck") + /// Cross-App-SSO: alle nativen mana-Apps teilen sich + /// `ManaSharedKeychainGroup` (= "ev.mana.session"), damit JWT + + /// Refresh-Token zwischen Apps geteilt werden können. Referenz + /// statt String-Literal, sonst driftet's bei jeder Plattform- + /// Aktualisierung. + @Test("Keychain-Service nutzt geteilte Mana-Group") + func keychainServiceUsesSharedGroup() { + #expect(AppConfig.manaAppConfig.keychainService == ManaSharedKeychainGroup) } } diff --git a/docs/OFFLINE_SYNC.md b/docs/OFFLINE_SYNC.md new file mode 100644 index 0000000..d6eb42c --- /dev/null +++ b/docs/OFFLINE_SYNC.md @@ -0,0 +1,244 @@ +# Offline-Sync — wordeck-native + +> **Status:** Konzept-Draft (2026-05-18). Implementierung als Phase +> ζ-1 / ζ-2 in `PLAN.md` geplant, noch nicht begonnen. + +## Ziel + +Alle Decks des Users — **eigene + abonnierte Marketplace-Forks** — +sollen automatisch beim Login / App-Foreground gecacht werden, so +dass der komplette **„Heute fällige Karten lernen"**-Pfad ohne Netz +funktioniert. Grades laufen wie heute über die `GradeQueue` und +drainen beim Reconnect. + +## Warum jetzt einfach + +Mit dem Wordeck-Text-Only-Rebrand (2026-05-17) sind Bilder und +Audio aus dem Schema raus. Eine Karte ist jetzt nur noch +`{type, fields: [String:String], deck_id, …}` — pure Text. Damit +ist die komplette Offline-Payload **JSON-only**: + +| Bestandteil | Größe pro Eintrag | Bei 10 000 Karten | +|---|---:|---:| +| Card-Record (text-only) | ≈ 300 B JSON | ≈ 3 MB | +| Review-Snapshot (FSRS-State) | ≈ 150 B | ≈ 1,5 MB | +| Distractor-Pool (nur MC, ≈ 10/Karte) | ≈ 500 B | ≈ 0,5 MB (pro MC-Karte) | + +Selbst Power-User mit 50 Decks und 5 000 Karten landen unter +**5 MB** Total-Footprint. SwiftData verkraftet das mit Links. + +## Server-Invariante bleibt + +**FSRS rechnet weiterhin nur am Server.** Lokales FSRS bleibt +verboten (CLAUDE.md §1). Der Offline-Modus ist ein **Snapshot- +Modell**: der Client lernt das, was der Server beim letzten Sync +als „due" markiert hat, schickt Grades hinterher, holt nach Sync +einen frischen Snapshot. Mehr ist nicht erlaubt. + +## Architektur + +``` +┌────────────────────────────────────────────────────────────┐ +│ DeckListStore.refresh() │ +│ │ +│ GET /decks ──┐ │ +│ ├── TaskGroup ── per Deck ──┬── listCards() │ +│ │ ├── dueReviews()│ +│ │ └── distractors │ +│ │ │ +│ ▼ │ +│ SwiftData-Persistenz │ +│ ┌─────────────┐ ┌────────────┐ ┌──────────────┐ │ +│ │ CachedDeck │ │ CachedCard │ │ CachedDue │ │ +│ │ │ │ │ │ Review │ │ +│ │ (heute) │ │ (neu) │ │ (neu) │ │ +│ └─────────────┘ └────────────┘ └──────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────┐ + │ StudySession.start() │ + │ │ + │ try: api.dueReviews(deckId) │ + │ fall: CachedDueReview (deckId) │ + │ │ + │ grade → GradeQueue (PendingGrade) │ + │ │ + └────────────────────────────────────────────┘ +``` + +## Daten-Modelle (neu) + +```swift +@Model +final class CachedCard { + @Attribute(.unique) var id: String // card_id + var deckId: String + var userId: String + var typeRaw: String // CardType.rawValue + var fields: [String: String] // pures JSON-Field-Bag + var contentHash: String? + var createdAt: Date + var updatedAt: Date + var lastFetchedAt: Date + + // Multiple-Choice-Pool für Offline-Rendering. + // Leer für non-MC-Karten. + var distractorPool: [String] = [] +} + +@Model +final class CachedDueReview { + @Attribute(.unique) var compoundId: String // "\(cardId)-\(subIndex)" + var cardId: String + var subIndex: Int + var deckId: String // Index für StudySession-Lookup + var due: Date // Server-berechnet + var stability: Double + var difficulty: Double + var stateRaw: String // ReviewState + var lastReview: Date? + var snapshottedAt: Date // wann gepullt +} +``` + +## API-Endpoints (vorhanden, kein Server-Change nötig) + +| Endpoint | Verwendung | Limit | +|---|---|---| +| `GET /api/v1/cards?deck_id=X` | komplette Card-Liste pro Deck | **kein Limit** | +| `GET /api/v1/reviews/due?deck_id=X&limit=500` | due-Snapshot | **500** ⚠ | +| `GET /api/v1/decks/:deckId/distractors?card_id=Y&field=back&count=10` | MC-Pool | 10 | + +## Sync-Algorithmus (`DeckListStore.refresh()` erweitert) + +``` +1. GET /decks → remoteDecks +2. Diff Cache ↔ remoteDecks, gelöschte Decks aus Cache entfernen +3. Für jedes Deck parallel (TaskGroup): + a. listCards(deckId) → in CachedCard upserten + b. dueReviews(deckId, limit: 500) → CachedDueReview ersetzen + (nicht mergen — Snapshot überschreibt komplett, weil due-Zeiten + sich serverseitig ändern können) + c. Für jede MC-Karte: distractors(deckId, cardId, count: 10) → + CachedCard.distractorPool +4. WidgetSnapshot updaten (heute schon, bleibt) +``` + +## StudySession-Anpassung + +```swift +func start() async { + phase = .loading + do { + queue = try await api.dueReviews(deckId: deckId, limit: 500) + // ... wie heute + } catch { + // Netz-Fehler → Cache befragen + queue = loadFromCache(deckId: deckId) + if queue.isEmpty { + phase = .failed("Kein Netz und keine gecachten Karten.") + } else { + Log.study.notice("Offline-Mode: \(queue.count) cached due reviews") + phase = .studying + isOfflineSession = true + } + } +} +``` + +Beim Grade-Submit ändert sich nichts: `GradeQueue.submit()` +persistiert eh erst lokal und drained später. Das funktioniert +heute schon offline. + +## Trigger + +| Wann | Was | +|---|---| +| App-Foreground / Login | `DeckListStore.refresh()` (heute) → erweitert auf Card+Review+Distractor-Sync | +| Pull-to-Refresh in `DeckListView` | dasselbe | +| Nach `subscribe(slug:)` im Marketplace | direkt `refresh()` aufrufen, damit das frisch abonnierte Deck sofort komplett gecacht ist | +| `BGAppRefreshTask` (alle ~12 h, optional, β-7-Polish) | Drain Grade-Queue + Refresh; nur wenn `wifi_only=true` erlaubt oder User hat Mobile-Sync aktiv | + +## Settings (in `SettingsView`) + +- **Auto-Sync** (Default: an) — schaltet Card/Review-Prefetch ein/aus +- **Background-Refresh** (Default: aus) — `BGAppRefreshTask` +- **Cache-Footprint anzeigen** — „17 Decks, 1 234 Karten, 4,2 MB" +- **Cache leeren** — Wipe aller `CachedCard` + `CachedDueReview`, + `CachedDeck` bleibt (sonst Deck-Liste leer) + +## Phasen + +| Phase | Inhalt | Aufwand | +|---|---|---| +| **ζ-1** | `CachedCard` + Sync in `DeckListStore`, `StudySession`-Cache-Fallback | 1 Tag | +| **ζ-2** | `CachedDueReview` + Distractor-Pool für MC-Karten | 0,5 Tag | +| **ζ-3** | `SettingsView`-Footprint + Cache-Clear | 0,5 Tag | +| **ζ-4** (optional) | `BGAppRefreshTask`, Wi-Fi-Only-Toggle | 0,5 Tag | + +Endurance-Pflicht (siehe `PLAN.md`): 200+ Karten offline lernen, +Flugmodus, alle Grades landen nach Reconnect am Server, Cross- +Check mit Web-Review-State. + +## Offene Punkte + +- 🛑 **`dueReviews(limit: 500)` ist hardcoded — Decks > 500 Karten + haben einen stillen Cap.** Wenn ein Marketplace-Deck mehr als + 500 fällige Karten hat (passiert bei frischen Abos), bekommt + der Client offline nur die ersten 500. Optionen: + - (a) Pagination einbauen (`offset=…`) und mehrere Calls + chainen — billig. + - (b) Server-Endpoint `/api/v1/reviews/due-all?deck_id=X` der + paginiert in einer Response liefert — sauberer, braucht + Backend-PR. + - (c) Aktzeptieren, Banner „Sync unvollständig — weitere + Karten erst nach Online-Refresh". + Vorschlag: **(a)** zunächst, Schwelle im Snapshot loggen. +- 🛑 **Distractor-Pool drifted, wenn der User Karten löscht.** + Ein Pool von 10 Distractors zur Sync-Zeit kann nach Lösch- + Aktionen Treffer in der Liste haben, die offline nicht mehr + existieren. Akzeptabel, weil MC-Distractors ohnehin + „Fülltext" sind — Reveal-Korrekt-Highlight kommt vom + `answer`-Feld der Karte, nicht aus dem Pool. +- 🛑 **„Mehr Karten als der Snapshot enthält"** — wenn User + offline alle 100 fälligen Karten durchgelernt hat und weiter + klickt, gibt es keinen lokalen Weg, „nächste fällige Karte" zu + bestimmen. UX-Honest: am Ende der Session Banner zeigen + („Weitere Karten erst nach Verbindung verfügbar"), Server- + authoritative-FSRS bleibt damit intakt. +- 🛑 **SwiftData-Migration.** Schema-Update von Build 11 → ζ-1 + legt zwei neue `@Model`-Klassen an. Bei In-Place-Upgrade von + TestFlight-Buildern muss der `ModelContainer` mit + `MigrationPlan` versorgt werden — sonst Crash beim ersten Start + nach Update. Wir haben das vorher noch nicht gebraucht; für ζ-1 + Pflicht-Aufgabe vor Submit. +- 🛑 **Cache-Invalidierung bei Cross-Device-Edits.** User editiert + Karte auf Web → Native zeigt offline noch alte Version, bis der + nächste Refresh läuft. Heute akzeptabel — `updatedAt`-Vergleich + beim Sync wirft die alte Version raus. Wenn das in der Praxis + weh tut, kann später ein Web-Push-Hook auf `card.updated` + einen Targeted-Refresh triggern (nicht ζ-Scope). +- 🛑 **Logout = Cache-Wipe.** Bei Sign-out alle `CachedCard` + + `CachedDueReview` löschen. Heute macht `auth.signOut()` das + nicht. Muss in ζ-1 mit rein. + +## Was *nicht* in ζ kommt + +- **Lokales FSRS-Berechnen** — verboten per CLAUDE.md §1. +- **Offline-Card-Create** — Editor bleibt online-only. Drafting + ohne Netz wäre nett, hat aber Konflikt-Auflösung als Folge- + Problem. Aufgeschoben bis nach v1. +- **Media-Prefetch** — gegenstandslos seit Wordeck-Rebrand + (text-only). + +## Cross-Refs + +- `CLAUDE.md` — Architektur-Invarianten (§1 FSRS, §2 Offline-Read) +- `PLAN.md` — Phasen-Stand +- `../mana/docs/playbooks/WORDECK_REBRAND.md` — Text-Only-Cut +- `../mana/docs/playbooks/CARDS_NATIVE_GREENFIELD.md` — Greenfield-SOT +- `../wordeck/apps/api/src/routes/cards.ts` — `GET /cards?deck_id` +- `../wordeck/apps/api/src/routes/reviews.ts` — `GET /reviews/due` +- `../wordeck/apps/api/src/routes/decks.ts` — `/distractors` diff --git a/project.yml b/project.yml index c37aad7..4293d80 100644 --- a/project.yml +++ b/project.yml @@ -79,7 +79,6 @@ targets: - cards NSUserActivityTypes: - NSUserActivityTypeBrowsingWeb - NSPhotoLibraryUsageDescription: "Wordeck greift auf deine Fotos zu, damit du Bilder zu Image-Occlusion-Karten hinzufügen kannst." ITSAppUsesNonExemptEncryption: false entitlements: path: Sources/Resources/WordeckNative.entitlements