zitare-native/Sources/Core/Snapshot/SnapshotSync.swift
Till c89d48c6f6 ζ-2 native: SwiftData-Snapshot-Cache + DailyQuoteWidget
- SnapshotModels.swift: CachedQuote (slug-unique, themes/regions
  als CSV), SnapshotMeta (singleton mit lastSyncedAt + totalCount),
  SnapshotContainer.make() mit App-Group-Store-URL (Fallback auf
  App-Container für Dev ohne Apple-Dev-Portal-Setup)
- SnapshotSync (actor) mit injectable Loader für Tests: refresh /
  refreshIfStale / tryRefresh (fail-soft). Re-konsolidiert beim Pull
  (Update + Insert + Delete entzogene Slugs). 24h-Staleness-Default.
- DailyQuoteWidget: Hash-of-Day-Picker aus SwiftData, drei Sizes,
  Mitternacht-Refresh-Policy, Placeholder bei leerem Store. Widget-
  Target zieht SnapshotModels.swift mit (project.yml).
- ZitareNativeApp triggert SnapshotSync.tryRefresh() bei Launch +
  WidgetCenter.reloadAllTimelines() danach.
- AppConfig.snapshotURL = webBaseURL/index-min.json (Web-Endpoint
  noch nicht live, fail-soft).
- DeepLinkRouter Substring-Guard fix (`/t` statt `/t/` im
  Prefix-Array, sonst greift hasPrefix("/t//") nicht).
- 22 Tests grün (6 AppConfig + 11 DeepLinkRouter + 3 SnapshotSync +
  1 UI + 1 Widget-Compile-Smoke), swiftlint 0 violations in 22 Files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:16:05 +02:00

204 lines
7.1 KiB
Swift

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<SnapshotMeta>())
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<CachedQuote>())
var byslug: [String: CachedQuote] = [:]
for quote in existing {
byslug[quote.slug] = quote
}
var keepSlugs = Set<String>()
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<SnapshotMeta>())
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)
}
}