feat(offline): text-only Cleanup + ζ-1 Offline-Sync
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) <noreply@anthropic.com>
This commit is contained in:
parent
19fee75c47
commit
9527240bcc
36 changed files with 728 additions and 1565 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue