pageta-native/Sources/Core/API/PagetaAPI.swift
Till JS f6eae77216 fix: geteilte Links zuverlässig extrahieren + Retry-Button im Reader
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>
2026-06-01 14:05:34 +02:00

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))"
}
}
}