- 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>
204 lines
7.1 KiB
Swift
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)
|
|
}
|
|
}
|