import Foundation import SwiftData /// Liest `index-min.json` aus dem Web-Surface und persistiert die Quotes /// in den App-Group-`ModelContainer`, damit Widget + Spotlight + native /// Surfaces ohne Live-API-Call rendern können. /// /// **Vertrag mit dem Web** (siehe /// `zitare/apps/api/src/jobs/snapshot.ts`): /// /// ```json /// { /// "generatedAt": "ISO8601", /// "count": N, /// "quotes": [ /// { "slug": "...", "authorSlug": "...", "authorName": "...", /// "language": "de", "themeSlugs": [...], "regionSlugs": [...] } /// ] /// } /// ``` /// /// **Endpoint** ist heute noch nicht live (`/index-min.json` wird nicht /// als HTTP-Route exposed). Die Sync-Klasse nimmt eine URL als Argument, /// damit Tests gegen einen lokalen Bundle-Resource laufen und Release /// auf den finalen URL umgestellt werden kann sobald /// `zitare/apps/zitare/src/routes/(read)/index-min.json/+server.ts` /// (oder die Static-File-Copy aus snapshot.ts) gebaut ist. /// Loader-Abstraktion: ProductionLoader nutzt URLSession, Tests können /// einen Inline-Loader injecten und Fixtures liefern, ohne dass /// Foundation tatsächlich übers Netz geht. typealias SnapshotLoader = @Sendable () async throws -> Data actor SnapshotSync { private let loader: SnapshotLoader private let container: ModelContainer private let url: URL /// Default-Staleness, ab der ein Refresh sinnvoll wird (24h). private let staleAfter: TimeInterval init( container: ModelContainer, url: URL = AppConfig.snapshotURL, loader: SnapshotLoader? = nil, staleAfter: TimeInterval = 24 * 60 * 60 ) { self.container = container self.url = url if let loader { self.loader = loader } else { self.loader = Self.urlSessionLoader(url: url) } self.staleAfter = staleAfter } private static func urlSessionLoader(url: URL) -> SnapshotLoader { { let (data, response) = try await URLSession.shared.data(from: url) guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { throw SnapshotSyncError.badResponse((response as? HTTPURLResponse)?.statusCode ?? -1) } return data } } /// Convenience-Init für Tests, die Bytes direkt liefern. static func forTesting( container: ModelContainer, url: URL = URL(string: "https://test.local/snap")!, staleAfter: TimeInterval = 0, data: @escaping @Sendable () -> Data ) -> SnapshotSync { SnapshotSync( container: container, url: url, loader: { data() }, staleAfter: staleAfter ) } /// Holt + persistiert nur, wenn der lokale Stand älter als /// `staleAfter` ist (oder gar nicht existiert). No-op sonst. func refreshIfStale() async throws { if try await !isStale() { Log.snapshot.info("Snapshot ist frisch, kein Refresh") return } try await refresh() } /// Erzwingt einen Pull, ignoriert Staleness. func refresh() async throws { let snapshotURL = url Log.snapshot.info("Snapshot-Pull: \(snapshotURL.absoluteString, privacy: .public)") let data = try await loader() let payload = try JSONDecoder.snapshot.decode(SnapshotPayload.self, from: data) try await persist(payload) Log.snapshot.info("Snapshot persistiert: \(payload.count) Quotes") } /// Wird vom Widget direkt aufgerufen, wenn der Timeline-Provider /// einen Refresh braucht. Kein Throw — Fail-soft, das Widget zeigt /// in dem Fall den letzten lokal vorhandenen Stand. func tryRefresh() async { do { try await refreshIfStale() } catch { Log.snapshot.warning("Snapshot-Refresh fehlgeschlagen: \(String(describing: error), privacy: .public)") } } private func isStale() async throws -> Bool { let context = ModelContext(container) let metas = try context.fetch(FetchDescriptor()) guard let last = metas.first?.lastSyncedAt else { return true } return Date().timeIntervalSince(last) > staleAfter } private func persist(_ payload: SnapshotPayload) async throws { let context = ModelContext(container) // Existing-Slugs als Set für O(1) lookup. let existing = try context.fetch(FetchDescriptor()) var byslug: [String: CachedQuote] = [:] for quote in existing { byslug[quote.slug] = quote } var keepSlugs = Set() let importedAt = Date() for quote in payload.quotes { keepSlugs.insert(quote.slug) if let model = byslug[quote.slug] { model.text = quote.text model.authorSlug = quote.authorSlug model.authorName = quote.authorName model.language = quote.language model.themesCSV = (quote.themeSlugs ?? []).joined(separator: ",") model.regionsCSV = (quote.regionSlugs ?? []).joined(separator: ",") model.importedAt = importedAt } else { let model = CachedQuote( slug: quote.slug, text: quote.text, authorSlug: quote.authorSlug, authorName: quote.authorName, language: quote.language, themes: quote.themeSlugs ?? [], regions: quote.regionSlugs ?? [], importedAt: importedAt ) context.insert(model) } } // Quotes, die der Server zurückgezogen hat, lokal löschen. for (slug, model) in byslug where !keepSlugs.contains(slug) { context.delete(model) } // Meta upserten. let metas = try context.fetch(FetchDescriptor()) let meta = metas.first ?? SnapshotMeta() if metas.isEmpty { context.insert(meta) } meta.generatedAt = SnapshotDate.parse(payload.generatedAt) meta.lastSyncedAt = importedAt meta.totalCount = payload.quotes.count try context.save() } } // MARK: - DTOs struct SnapshotPayload: Decodable { let generatedAt: String let count: Int let quotes: [SnapshotQuote] } struct SnapshotQuote: Decodable { let slug: String let text: String let authorSlug: String? let authorName: String? let language: String? let themeSlugs: [String]? let regionSlugs: [String]? } enum SnapshotSyncError: Error { case badResponse(Int) } // MARK: - Decoders extension JSONDecoder { static let snapshot: JSONDecoder = .init() } /// Wrapper, der `ISO8601DateFormatter` thread-sicher kapselt. Apple's /// Formatter ist nicht `Sendable`; wir bauen pro Call einen frischen. enum SnapshotDate { static func parse(_ raw: String) -> Date? { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = f.date(from: raw) { return date } f.formatOptions = [.withInternetDateTime] return f.date(from: raw) } }