Grundproblem: geteilte Links werden local-first als Stub mit leerem Inhalt gespeichert; die Nach-Extraktion war für den Nutzer unsichtbar und an mehreren Stellen fragil. Folge: Artikel hingen dauerhaft beim "wird noch geladen"-Hinweis, selbst nach Login, ohne Retry-Möglichkeit. - Leere Extraktion gilt nicht mehr als Erfolg: PagetaAPI.enrichArticle wirft PagetaSyncError.extractionEmpty, wenn der Server keinen Text liefert (und keine Consent-Wall-Warnung). Vorher emittierte der Coordinator ein ArticleEnriched mit leerem content -> Artikel galt als "fertig" und blieb für immer leer. HTML bleibt für Retry erhalten. - ArticleDetailView lädt bei leerem Inhalt jetzt aktiv nach (load() ruft extractContent einmal pro Artikel, eingeloggt) statt nur passiv den Hinweis zu zeigen — genau dort, wo der Nutzer steht. - Retry-Button "Erneut verarbeiten" im Pending-Hinweis: hebt die Session-Fehlersperre auf (clearEnrichmentFailure) und stößt die Extraktion direkt an, mit Loading-/Fehler-Zustand. - Hinweis-Text ehrlich: lädt / fehlgeschlagen (mit Grund + Consent-Wall- Tipp) / noch nicht geladen — statt pauschal "lädt automatisch nach". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
import Foundation
|
|
import ManaCore
|
|
|
|
/// HTTP-Client + Event-Sync-Bridge für die Pageta-Daten.
|
|
///
|
|
/// **HTTP-only gegen `api.pageta.com`** (Server-Compute, kein User-State):
|
|
/// - `/api/v1/feed` — kuratierter Pool-Feed
|
|
/// - `/api/v1/articles/extract/preview` — Readability-Extraktion
|
|
/// - `/api/v1/snapshot/widget` — Widget-Lean-Payload
|
|
///
|
|
/// **Event-sourced gegen `sync2.mana.how`** (User-owned, π-11):
|
|
/// - Lese-Liste, Highlights, Tags. Routet über
|
|
/// `EventSyncCoordinator.shared`. Wenn der Coordinator nicht
|
|
/// initialisiert ist (Bootstrap-Bug), wirft die Methode
|
|
/// `PagetaSyncError.eventSyncUnavailable`.
|
|
///
|
|
/// Pattern wie HerbatriumAPI/NutriphiAPI: Views instanziieren per-View
|
|
/// mit dem injizierten `AuthClient`. Der zugrunde liegende
|
|
/// `AuthenticatedTransport` ist ein actor und teilt sich Token-State.
|
|
final class PagetaAPI: Sendable {
|
|
private let transport: AuthenticatedTransport
|
|
private let decoder: JSONDecoder
|
|
|
|
init(auth: AuthClient) {
|
|
self.transport = AuthenticatedTransport(
|
|
baseURL: AppConfig.apiBaseURL,
|
|
auth: auth
|
|
)
|
|
self.decoder = JSONDecoder()
|
|
}
|
|
|
|
// MARK: - Feed (HTTP)
|
|
|
|
/// `GET /api/v1/feed` — kuratierter News-Feed aus dem mana-news-pool.
|
|
/// Server liefert vorgewichtete Items (score aus user-Preferences +
|
|
/// Reactions), Native zeigt in dieser Reihenfolge.
|
|
func fetchFeed(limit: Int = 50) async throws -> [FeedItem] {
|
|
let (data, response) = try await transport.request(
|
|
path: "/api/v1/feed?limit=\(limit)"
|
|
)
|
|
try checkStatus(response, data: data)
|
|
let envelope = try decoder.decode(FeedEnvelope.self, from: data)
|
|
return envelope.articles
|
|
}
|
|
|
|
private struct FeedEnvelope: Decodable {
|
|
let count: Int
|
|
let articles: [FeedItem]
|
|
}
|
|
|
|
// MARK: - Saved Articles (event-sync)
|
|
|
|
/// Save-it-later: erst `extract/preview` über HTTP, dann
|
|
/// `ArticleSaved` an sync2. Duplicate-Check lokal über urlHash.
|
|
func saveArticle(url: URL, userNote: String? = nil) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
let preview = try await extractPreview(url: url)
|
|
_ = try await coordinator.saveArticle(url: url, userNote: userNote, preview: preview)
|
|
}
|
|
|
|
/// Local-first deferred save für aus Safari geteilte Links: legt den
|
|
/// Artikel **sofort lokal** an (leerer Inhalt, Host als Platzhalter-Titel)
|
|
/// — funktioniert auch ohne gültige Session. Die Extraktion holt
|
|
/// `enrichPendingArticles()` nach, sobald eine Session da ist. Danach
|
|
/// versucht es einmal opportunistisch direkt anzureichern (No-op bei 401).
|
|
func saveSharedArticle(url: URL, userNote: String? = nil) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
let article = try await coordinator.saveArticleStub(url: url, userNote: userNote)
|
|
// Best-effort sofort-Anreicherung; scheitert ausgeloggt mit 401 und
|
|
// wird dann vom Sweep nachgeholt.
|
|
try? await enrichArticle(articleId: article.id, url: url)
|
|
}
|
|
|
|
/// Extrahiert den Inhalt eines minimal gespeicherten Artikels und emittiert
|
|
/// `ArticleEnriched`. Wirft bei fehlender Session (401) — Caller behandelt.
|
|
///
|
|
/// Wenn die ShareExtension gerendertes HTML aus der zustimmenden Safari-
|
|
/// Session abgelegt hat (`SharedHTMLStore`), schicken wir das mit — das
|
|
/// umgeht Consent-/Cookie-Wände, die der anonyme Server-Fetch sonst
|
|
/// extrahiert. Das HTML wird erst nach erfolgreicher Extraktion verworfen,
|
|
/// damit ein 401/502-Retry (Sweep) es erneut nutzen kann.
|
|
func enrichArticle(articleId: String, url: URL) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
let htmlStore = SharedHTMLStore()
|
|
let html = htmlStore.peek(for: url)
|
|
let preview = try await extractPreview(url: url, html: html)
|
|
// Leere Extraktion ist KEIN Erfolg: ohne diesen Guard emittiert der
|
|
// Coordinator ein ArticleEnriched mit leerem Inhalt, der Artikel gilt
|
|
// als „fertig" und hängt für immer beim Pending-Hinweis. Bei einer
|
|
// Consent-Wall-Warnung emittieren wir trotzdem — dann zeigt der Reader
|
|
// das Wall-Banner statt des irreführenden „wird geladen". Das HTML
|
|
// bleibt erhalten, damit ein späterer Retry es erneut nutzen kann.
|
|
let hasText = !preview.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
guard hasText || preview.warning != nil else {
|
|
throw PagetaSyncError.extractionEmpty
|
|
}
|
|
try await coordinator.enrichArticle(articleId: articleId, preview: preview)
|
|
if html != nil { htmlStore.remove(for: url) }
|
|
}
|
|
|
|
/// Hebt die Session-Sperre eines fehlgeschlagenen Artikels auf, damit der
|
|
/// nächste `enrichArticle`/Sweep ihn erneut versucht (Retry-Button).
|
|
func clearEnrichmentFailure(articleId: String) async {
|
|
await EventSyncCoordinator.shared?.clearEnrichmentFailure(articleId: articleId)
|
|
}
|
|
|
|
/// Sweep: reichert alle inhaltsleeren Artikel an, sobald die Extraktion
|
|
/// möglich ist. Pro Artikel best-effort — scheitert die Extraktion
|
|
/// (keine Session / 502), wird der Artikel für diese Session als
|
|
/// fehlgeschlagen markiert und nicht erneut versucht.
|
|
/// Returns: Anzahl erfolgreich angereicherter Artikel (für UI-Refresh).
|
|
@discardableResult
|
|
func enrichPendingArticles() async -> Int {
|
|
guard let coordinator = await EventSyncCoordinator.shared else { return 0 }
|
|
let pending = (try? await coordinator.articlesNeedingEnrichment()) ?? []
|
|
var enriched = 0
|
|
for article in pending {
|
|
guard let url = URL(string: article.originalUrl) else { continue }
|
|
do {
|
|
try await enrichArticle(articleId: article.id, url: url)
|
|
enriched += 1
|
|
} catch {
|
|
await coordinator.markEnrichmentFailed(articleId: article.id)
|
|
}
|
|
}
|
|
return enriched
|
|
}
|
|
|
|
private func extractPreview(url: URL, html: String? = nil) async throws -> ArticleExtractPreview {
|
|
var payload: [String: Any] = ["url": url.absoluteString]
|
|
// Gerendertes HTML mitschicken, wenn vorhanden + nicht zu groß (die API
|
|
// lehnt > 5 000 000 Zeichen mit 400 ab → dann lieber URL-Pfad).
|
|
if let html, html.count <= SharedHTMLStore.maxHTMLChars {
|
|
payload["html"] = html
|
|
}
|
|
let body = try JSONSerialization.data(withJSONObject: payload)
|
|
let (data, response) = try await transport.request(
|
|
path: "/api/v1/articles/extract/preview",
|
|
method: "POST",
|
|
body: body
|
|
)
|
|
try checkStatus(response, data: data)
|
|
return try decoder.decode(ArticleExtractPreview.self, from: data)
|
|
}
|
|
|
|
func fetchArticle(id: String) async throws -> Article {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
return try await coordinator.getArticle(id: id)
|
|
}
|
|
|
|
func fetchArticles(status: ArticleStatus? = nil, limit: Int = 50) async throws -> [Article] {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
return try await coordinator.listArticles(status: status, limit: limit)
|
|
}
|
|
|
|
func updateArticle(
|
|
id: String,
|
|
status: ArticleStatus? = nil,
|
|
isFavorite: Bool? = nil,
|
|
readingProgress: Double? = nil
|
|
) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.updateArticle(
|
|
id: id,
|
|
status: status,
|
|
isFavorite: isFavorite,
|
|
readingProgress: readingProgress
|
|
)
|
|
}
|
|
|
|
func deleteArticle(id: String) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.deleteArticle(id: id)
|
|
}
|
|
|
|
// MARK: - Highlights (event-sync)
|
|
|
|
func fetchHighlights(articleId: String) async throws -> [Highlight] {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
return try await coordinator.listHighlights(articleId: articleId)
|
|
}
|
|
|
|
/// Alle Highlights über alle Artikel — backend für MarkierungenTabView.
|
|
func fetchAllHighlights() async throws -> [HighlightWithArticle] {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
return try await coordinator.listAllHighlights()
|
|
}
|
|
|
|
func addHighlight(articleId: String, text: String, color: HighlightColor) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.addHighlight(articleId: articleId, text: text, color: color)
|
|
}
|
|
|
|
func deleteHighlight(id: String) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.deleteHighlight(id: id)
|
|
}
|
|
|
|
func setHighlightNote(id: String, note: String?) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.setHighlightNote(id: id, note: note)
|
|
}
|
|
|
|
func setHighlightColor(id: String, color: HighlightColor) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.setHighlightColor(id: id, color: color)
|
|
}
|
|
|
|
// MARK: - Widget Snapshot (HTTP)
|
|
|
|
/// `GET /api/v1/snapshot/widget` — Lean-Payload für WidgetKit
|
|
/// (Top-3 Feed-Items + Saved-Counts pro Status). Native cached
|
|
/// das Result in App-Group-UserDefaults; das Widget liest dort
|
|
/// und redet nicht selbst mit dem Server.
|
|
///
|
|
/// **Anmerkung π-11:** Der Server-Endpoint liefert Saved-Counts noch
|
|
/// aus der Legacy-DB — bis das umgestellt ist, sind die Zahlen ggf.
|
|
/// veraltet, wenn Native via event-sync gespeichert hat aber das
|
|
/// Web-Backfill noch nicht durch ist.
|
|
func fetchWidgetSnapshot() async throws -> WidgetSnapshot {
|
|
let (data, response) = try await transport.request(
|
|
path: "/api/v1/snapshot/widget"
|
|
)
|
|
try checkStatus(response, data: data)
|
|
return try decoder.decode(WidgetSnapshot.self, from: data)
|
|
}
|
|
|
|
// MARK: - Tags (event-sync)
|
|
|
|
func addTag(articleId: String, tag: String) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.addTag(articleId: articleId, tag: tag)
|
|
}
|
|
|
|
/// Setzt die kompletten Tags eines Artikels (Remove-by-pattern:
|
|
/// aktuelle Tag-Liste ohne den entfernten Eintrag durchreichen).
|
|
func setArticleTags(articleId: String, tags: [String]) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.setArticleTags(articleId: articleId, tags: tags)
|
|
}
|
|
|
|
/// Räumt eine Cookie-Wall-Warnung am Artikel weg — UI „Hinweis
|
|
/// ausblenden". Cross-device durch shared-schemas 0.1.11.
|
|
func dismissArticleWarning(articleId: String) async throws {
|
|
guard let coordinator = await EventSyncCoordinator.shared else {
|
|
throw PagetaSyncError.eventSyncUnavailable
|
|
}
|
|
try await coordinator.dismissWarning(articleId: articleId)
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
private func checkStatus(_ response: HTTPURLResponse, data: Data) throws {
|
|
guard (200..<300).contains(response.statusCode) else {
|
|
let body = String(data: data, encoding: .utf8) ?? ""
|
|
throw PagetaAPIError.http(status: response.statusCode, body: body)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum PagetaAPIError: Error, LocalizedError {
|
|
case http(status: Int, body: String)
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case let .http(status, body):
|
|
"API-Fehler \(status): \(body.prefix(200))"
|
|
}
|
|
}
|
|
}
|