From 61927d27a3e42ca455f224f1a38319a2dc355497 Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 19 May 2026 16:22:52 +0200 Subject: [PATCH] =?UTF-8?q?=CE=B6-3.5:=20Offline-Submit-Queue=20mit=20Swif?= =?UTF-8?q?tData=20+=20Auto-Retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bei Network-Failure landet der Quote-Draft jetzt in einer persistenten SwiftData-Queue (\`PendingSubmission\`) statt im Error-Banner. Beim nächsten App-Launch ODER beim Wechsel auf scenePhase.active wird der Flush automatisch versucht. Retry-Policy: - 5xx oder Transport-Failure (NSURLErrorDomain) → in Queue, Retry - 4xx mit code (validation_failed, duplicate, unauthorized) → permanenter Fehler, kein Retry (User-Aktion nötig) - Hard-Limit 50 Retries pro Entry, danach pausiert App-Group-Store \`submissions.store\` (parallel zu snapshot.store) im \`group.ev.mana.zitare\`-Container. Fallback auf In-Memory falls Disk-Init scheitert (App-Group noch nicht aktiviert im Apple-Dev-Portal). UI-Pieces: - Pending-Banner zeigt Queue-Tiefe wenn > 0 - Queued-Banner nach erfolgreichem Enqueue - Form-Reset nach Enqueue (User sieht: "weg, kommt nach") - onChange(scenePhase) → Auto-Flush bei Foreground - ZitareNativeApp.task: Flush am Launch Files: - Sources/Core/Submit/PendingSubmissionModel.swift (neu, @Model) - Sources/Core/Submit/SubmissionQueue.swift (neu, @Observable @MainActor) - Sources/App/ZitareNativeApp.swift: Container-Init + environment-Wiring - Sources/Features/Submit/SubmitQuoteView.swift: enqueue + flush + banners iOS + macOS BUILD SUCCEEDED. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/App/ZitareNativeApp.swift | 23 ++++ .../Core/Submit/PendingSubmissionModel.swift | 84 +++++++++++++ Sources/Core/Submit/SubmissionQueue.swift | 115 ++++++++++++++++++ Sources/Features/Submit/SubmitQuoteView.swift | 89 +++++++++++++- 4 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 Sources/Core/Submit/PendingSubmissionModel.swift create mode 100644 Sources/Core/Submit/SubmissionQueue.swift diff --git a/Sources/App/ZitareNativeApp.swift b/Sources/App/ZitareNativeApp.swift index 7dc58a2..8e22604 100644 --- a/Sources/App/ZitareNativeApp.swift +++ b/Sources/App/ZitareNativeApp.swift @@ -8,6 +8,7 @@ import WidgetKit struct ZitareNativeApp: App { @State private var auth: AuthClient @State private var authGate: ManaAuthGate + @State private var submissionQueue: SubmissionQueue private let snapshotContainer: ModelContainer? init() { @@ -23,6 +24,18 @@ struct ZitareNativeApp: App { ) snapshotContainer = nil } + let pending: ModelContainer + do { + pending = try PendingSubmissionContainer.make() + } catch { + Log.app.error( + "PendingSubmissionContainer-Disk init fehlgeschlagen, falle auf in-memory zurück: \(String(describing: error), privacy: .public)" + ) + // In-memory-Fallback statt nil. Submissions sind dann nur + // für die aktuelle Session persistiert — besser als Crash. + pending = try! PendingSubmissionContainer.make(inMemory: true) + } + _submissionQueue = State(initialValue: SubmissionQueue(container: pending)) Log.app.info( "Zitare starting — auth status: \(String(describing: auth.status), privacy: .public)" ) @@ -33,13 +46,23 @@ struct ZitareNativeApp: App { RootView() .environment(auth) .environment(authGate) + .environment(submissionQueue) .tint(ZitareTheme.primary) .task { await refreshSnapshot() + await flushPending() } } } + private func flushPending() async { + let api = ZitareAPI(auth: auth) + let sent = await submissionQueue.tryFlush(api: api) + if sent > 0 { + Log.app.info("Auto-flushed \(sent) pending submission(s) at launch") + } + } + private func refreshSnapshot() async { guard let container = snapshotContainer else { return } let sync = SnapshotSync(container: container) diff --git a/Sources/Core/Submit/PendingSubmissionModel.swift b/Sources/Core/Submit/PendingSubmissionModel.swift new file mode 100644 index 0000000..03e49c5 --- /dev/null +++ b/Sources/Core/Submit/PendingSubmissionModel.swift @@ -0,0 +1,84 @@ +import Foundation +import SwiftData + +/// Pending Quote-Submission, die wegen Network-Fehler nicht durchging. +/// Wird beim nächsten Foreground/Reconnect via `SubmissionQueue.tryFlush` +/// nachgereicht. +/// +/// Eigener Store (`submissions.store`) im App-Group-Container, damit +/// Snapshot-Sync und Pending-Submissions sich nicht in den Quere kommen. +@Model +final class PendingSubmission { + @Attribute(.unique) var id: UUID + var createdAt: Date + var lastTriedAt: Date? + var retryCount: Int + var lastError: String? + + // QuoteDraft-Felder, flach gespeichert für SwiftData-Kompatibilität. + var text: String + var language: String + var authorName: String? + var authorSlug: String? + var sourceTitle: String? + var sourceKindRaw: String? + var sourceYear: Int? + var editReason: String? + var acceptedTos: Bool + + init(draft: QuoteDraft, id: UUID = UUID(), createdAt: Date = Date()) { + self.id = id + self.createdAt = createdAt + self.retryCount = 0 + self.text = draft.text + self.language = draft.language + self.authorName = draft.authorName + self.authorSlug = draft.authorSlug + self.sourceTitle = draft.sourceTitle + self.sourceKindRaw = draft.sourceKind?.rawValue + self.sourceYear = draft.sourceYear + self.editReason = draft.editReason + self.acceptedTos = draft.acceptedTos + } + + func toDraft() -> QuoteDraft { + QuoteDraft( + text: text, + language: language, + authorName: authorName, + authorSlug: authorSlug, + sourceTitle: sourceTitle, + sourceKind: sourceKindRaw.flatMap(QuoteDraft.SourceKind.init(rawValue:)), + sourceYear: sourceYear, + editReason: editReason, + acceptedTos: acceptedTos + ) + } +} + +/// Helper für den ModelContainer. Eigener Store, damit das +/// Snapshot-Schema nicht mit-migriert wird, wenn wir Submission- +/// Felder ändern. +enum PendingSubmissionContainer { + static let appGroup = "group.ev.mana.zitare" + + static func defaultStoreURL() -> URL { + let fm = FileManager.default + if let groupURL = fm.containerURL(forSecurityApplicationGroupIdentifier: appGroup) { + return groupURL.appendingPathComponent("submissions.store") + } + let docs = fm.urls(for: .documentDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory()) + return docs.appendingPathComponent("submissions.store") + } + + static func make(inMemory: Bool = false) throws -> ModelContainer { + let schema = Schema([PendingSubmission.self]) + let config: ModelConfiguration = if inMemory { + ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + } else { + ModelConfiguration("submissions", schema: schema, url: defaultStoreURL()) + } + return try ModelContainer(for: schema, configurations: [config]) + } +} diff --git a/Sources/Core/Submit/SubmissionQueue.swift b/Sources/Core/Submit/SubmissionQueue.swift new file mode 100644 index 0000000..7b498eb --- /dev/null +++ b/Sources/Core/Submit/SubmissionQueue.swift @@ -0,0 +1,115 @@ +import Foundation +import ManaCore +import SwiftData + +/// Verwaltet die `PendingSubmission`-Queue. Hauptverantwortung: Drafts +/// persistieren bei Network-Failure, beim Foreground/Reconnect via +/// `tryFlush(api:)` einen Retry-Run starten. +/// +/// **Retry-Strategie:** alle wartenden Drafts werden sequentiell gesendet. +/// Bei `400/409/401` (Server-Code) wird der Eintrag **gelöscht** — das +/// sind permanente Fehler (validation_failed, duplicate, unauthorized), +/// die ein Retry nicht rettet. Bei Network-/5xx-Fehler bleibt der Draft +/// in der Queue mit `lastError` für Diagnose; `retryCount++`. +/// +/// **Hartes Limit:** 50 Retries pro Eintrag, danach wird er als +/// "permanent failed" markiert (`retryCount > 50`) und nicht mehr +/// versucht — User muss manuell löschen oder neu eintragen. +@MainActor +@Observable +final class SubmissionQueue { + let container: ModelContainer + private var inFlight: Bool = false + + init(container: ModelContainer) { + self.container = container + } + + /// Hängt einen Draft an die Queue. + func deposit(_ draft: QuoteDraft, error: String? = nil) { + let ctx = ModelContext(container) + let entry = PendingSubmission(draft: draft) + entry.lastError = error + ctx.insert(entry) + try? ctx.save() + Log.app.info("Submission deposited (queue depth: \(self.queueDepth()))") + } + + /// Anzahl der wartenden Submissions. + func queueDepth() -> Int { + let ctx = ModelContext(container) + let descriptor = FetchDescriptor() + return (try? ctx.fetch(descriptor).count) ?? 0 + } + + /// Holt alle pending Drafts (für UI). + func loadAll() -> [PendingSubmission] { + let ctx = ModelContext(container) + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.createdAt)] + ) + return (try? ctx.fetch(descriptor)) ?? [] + } + + /// Löscht eine Submission manuell aus der Queue. + func delete(id: UUID) { + let ctx = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + if let row = try? ctx.fetch(descriptor).first { + ctx.delete(row) + try? ctx.save() + } + } + + /// Versucht alle wartenden Submissions zu senden. Returns count of + /// erfolgreich gesendeter Items. + @discardableResult + func tryFlush(api: ZitareAPI) async -> Int { + guard !inFlight else { return 0 } + inFlight = true + defer { inFlight = false } + + let ctx = ModelContext(container) + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.retryCount <= 50 }, + sortBy: [SortDescriptor(\.createdAt)] + ) + guard let entries = try? ctx.fetch(descriptor), !entries.isEmpty else { + return 0 + } + + Log.app.info("Flushing \(entries.count) pending submission(s)") + var sent = 0 + for entry in entries { + let draft = entry.toDraft() + do { + _ = try await api.submitQuote(draft) + ctx.delete(entry) + sent += 1 + } catch let error as ZitareAPIError where shouldDrop(error) { + Log.app.warning("Dropping pending submission (permanent error: \(error.code ?? "?", privacy: .public))") + entry.lastError = error.errorDescription + ctx.delete(entry) + } catch let error as LocalizedError { + entry.lastError = error.errorDescription + entry.lastTriedAt = Date() + entry.retryCount += 1 + } catch { + entry.lastError = String(describing: error) + entry.lastTriedAt = Date() + entry.retryCount += 1 + } + } + try? ctx.save() + Log.app.info("Flush done — sent \(sent)/\(entries.count)") + return sent + } + + /// 4xx-Errors sind permanent (validation, auth, duplicate) → Drop. + /// 5xx und Transport-Failure → Retry. + private func shouldDrop(_ error: ZitareAPIError) -> Bool { + error.status >= 400 && error.status < 500 + } +} diff --git a/Sources/Features/Submit/SubmitQuoteView.swift b/Sources/Features/Submit/SubmitQuoteView.swift index a473a44..80d815e 100644 --- a/Sources/Features/Submit/SubmitQuoteView.swift +++ b/Sources/Features/Submit/SubmitQuoteView.swift @@ -19,12 +19,16 @@ import SwiftUI struct SubmitQuoteView: View { @Environment(AuthClient.self) private var auth @Environment(ManaAuthGate.self) private var authGate + @Environment(SubmissionQueue.self) private var submissionQueue + @Environment(\.scenePhase) private var scenePhase @State private var draft = QuoteDraft.empty @State private var includesSource = false @State private var submitting = false @State private var lastError: String? @State private var lastSuccessSlug: String? + @State private var queuedForRetry: Bool = false + @State private var pendingCount: Int = 0 @FocusState private var textFocused: Bool var body: some View { @@ -34,9 +38,15 @@ struct SubmitQuoteView: View { authorSection sourceSection licenseSection + if pendingCount > 0 { + Section { pendingBanner } + } if let lastError { Section { errorBanner(lastError) } } + if queuedForRetry { + Section { queuedBanner } + } if let lastSuccessSlug { Section { successBanner(slug: lastSuccessSlug) } } @@ -63,6 +73,14 @@ struct SubmitQuoteView: View { #if os(iOS) .navigationBarTitleDisplayMode(.inline) #endif + .task { + refreshPendingCount() + } + .onChange(of: scenePhase) { _, phase in + if phase == .active { + Task { await flushPending() } + } + } } } @@ -148,6 +166,29 @@ struct SubmitQuoteView: View { .font(.callout) } + private var pendingBanner: some View { + VStack(alignment: .leading, spacing: 4) { + Label("\(pendingCount) Submission(s) in der Warteschlange", systemImage: "tray.and.arrow.up") + .foregroundStyle(ZitareTheme.mutedForeground) + .font(.callout) + Text("Beim nächsten Verbindungsaufbau werden sie automatisch nachgereicht.") + .font(.caption) + .foregroundStyle(ZitareTheme.mutedForeground) + } + } + + private var queuedBanner: some View { + VStack(alignment: .leading, spacing: 4) { + Label("Offline gespeichert — wird automatisch nachgereicht", systemImage: "tray.and.arrow.up.fill") + .foregroundStyle(ZitareTheme.primary) + .font(.callout) + .fontWeight(.medium) + Text("Sobald wieder Netzwerk verfügbar ist, fliegt der Draft an die Moderation. Du musst nichts mehr tun.") + .font(.caption) + .foregroundStyle(ZitareTheme.mutedForeground) + } + } + private func successBanner(slug: String) -> some View { VStack(alignment: .leading, spacing: 4) { Label("Eingereicht — wartet auf Moderation", systemImage: "checkmark.seal.fill") @@ -217,13 +258,55 @@ struct SubmitQuoteView: View { lastSuccessSlug = result.slug draft = .empty includesSource = false - } catch let error as LocalizedError { - lastError = error.errorDescription ?? "Fehler" + queuedForRetry = false + } catch let apiError as ZitareAPIError { + // 4xx mit Code (validation_failed, duplicate, unauthorized) → + // permanenter Fehler, kein Retry. 5xx oder kein Code → Queue. + if apiError.status >= 500 || apiError.code == nil { + enqueue(payload, error: apiError.errorDescription) + } else { + lastError = apiError.errorDescription ?? "Fehler" + } } catch { - lastError = String(describing: error) + // URLError / Transport-Failure → ebenfalls in die Queue. + if isTransportError(error) { + enqueue(payload, error: String(describing: error)) + } else { + lastError = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + } } } + private func enqueue(_ payload: QuoteDraft, error: String?) { + submissionQueue.deposit(payload, error: error) + queuedForRetry = true + lastError = nil + draft = .empty + includesSource = false + refreshPendingCount() + Log.app.info("Submit fehlgeschlagen — in Queue gelegt (\(self.pendingCount) wartend)") + } + + private func isTransportError(_ error: Error) -> Bool { + let ns = error as NSError + return ns.domain == NSURLErrorDomain + } + + @MainActor + private func flushPending() async { + guard pendingCount > 0 else { return } + let api = ZitareAPI(auth: auth) + let sent = await submissionQueue.tryFlush(api: api) + if sent > 0 { + Log.app.info("Foreground-Flush: \(sent) Submission(s) nachgereicht") + } + refreshPendingCount() + } + + private func refreshPendingCount() { + pendingCount = submissionQueue.queueDepth() + } + private func label(for kind: QuoteDraft.SourceKind) -> LocalizedStringKey { switch kind { case .book: "Buch"