pageta-native/Sources/Core/Domain/Article.swift
Till JS ca4b17b1a3 feat: ArticleMediaArchived-Mirror + archivierte Vorschaubilder (Liste + Reader-Header)
Spiegel von @mana/shared-schemas/pageta@0.1.21 (Web = SOT):
- ArticleMediaArchivedPayload + Reducer-Case (non-destruktiv, if-let
  wie warning); ArticleEnriched clobbert imageUrl nicht mehr
- ArticleState.htmlContent; stateToArticle mappt es (war nil)
- ArticleExtractPreview dekodiert imageUrl (og:image-Hotlink)
- archiveMedia(): POST /articles/archive-media, fire-and-forget nach
  Save/Enrich; NUR die archivierte media.mana.how-URL wird emittiert,
  nie der Hotlink; Delete-Race-Guard wie im Web-Client
- MediaURL.variant (thumb/medium/large, Spiegel von media-url.ts)
- Lese-Liste: 56pt-Thumbnail; Reader-Header: Lead-Image (medium, 200pt)
- 5 neue Reducer-/MediaURL-Tests (54/54 grün, iPhone-17-Simulator)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:52:17 +02:00

147 lines
5.2 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import Foundation
enum ArticleStatus: String, Codable, Sendable, CaseIterable, Identifiable {
case unread
case reading
case finished
case archived
var id: String { rawValue }
var label: String {
switch self {
case .unread: PagetaStrings.statusUnread
case .reading: PagetaStrings.statusReading
case .finished: PagetaStrings.statusFinished
case .archived: PagetaStrings.statusArchived
}
}
}
/// Article aus der User-Lese-Liste (Save-it-later). Mirror von
/// `pageta-domain/types.ts#Article`. Datetime-Felder als ISO-Strings,
/// damit JSON-Decoding ohne DateFormatter-Setup läuft.
///
/// Mutable Felder (`var`) sind die, die per `PATCH /articles/:id`
/// vom User gesetzt werden — Native spiegelt Optimistic-Updates auf
/// die lokale Instanz, damit die UI sofort reagiert. Immutable Felder
/// (`let`) kommen vom Server und ändern sich erst beim nächsten Fetch.
struct Article: Codable, Sendable, Identifiable {
let id: String
let userId: String
let originalUrl: String
let title: String
let excerpt: String?
let content: String
let htmlContent: String?
let author: String?
let siteName: String?
let imageUrl: String?
let wordCount: Int?
let readingTimeMinutes: Int?
let publishedAt: String?
var status: ArticleStatus
var readingProgress: Double
var isFavorite: Bool
let savedAt: String
var readAt: String?
var userNote: String?
let extractedVersion: Int
let createdAt: String
var updatedAt: String
/// Vom Server beim Extract gesetzter Warn-Marker. Aktuell nur
/// `"probable_consent_wall"` — bei Save war der extrahierte Text
/// kurz und voller GDPR-Vokabular, vermutlich also kein Artikel
/// sondern eine Cookie-Wand. UI rendert dann Banner mit „Original
/// öffnen". Bleibt persistent im ArticleSaved-Event.
var warning: String?
/// User-Tags an diesem Artikel. Via `ArticleTagsSet`-Event (event-
/// sync, kein HTTP). Reihenfolge = Set-Reihenfolge des letzten
/// Events; UI-seitig dedupliziert und lowercased im Coordinator.
var tags: [String] = []
}
extension Article {
/// Titel ohne redundanten Quell-/Marken-Suffix („Headline – WinFuture",
/// „Headline | heise online"). Viele Seiten hängen ihre Marke an den
/// `<title>`, den Readability übernimmt — die Quelle (`siteName`) steht in
/// der UI aber ohnehin separat darüber. Bewusst **konservativ**: schneidet
/// nur hinter einem typischen Trenner und nur, wenn der Schwanz wirklich
/// zur Quelle passt; sonst bleibt der Titel unangetastet.
var displayTitle: String {
Article.titleWithoutSource(title: title, siteName: siteName)
}
static func titleWithoutSource(title: String, siteName: String?) -> String {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard let siteName, !siteName.isEmpty else { return trimmed }
let siteKey = brandKey(siteName)
guard !siteKey.isEmpty else { return trimmed }
// Reihenfolge egal — wir suchen den LETZTEN Trenner (`.backwards`).
let separators = [" — ", " – ", " - ", " | ", " · ", " :: ", " » ", " • "]
for sep in separators {
guard let range = trimmed.range(of: sep, options: .backwards) else { continue }
let head = trimmed[..<range.lowerBound].trimmingCharacters(in: .whitespaces)
let tail = String(trimmed[range.upperBound...])
if !head.isEmpty, brandKey(tail) == siteKey {
return head
}
}
return trimmed
}
/// Normalisiert Quelle/Marke auf einen Vergleichsschlüssel: lowercased,
/// `www.` weg, bei Host-Form nur der Teil vor dem ersten Punkt
/// (`winfuture.de` → `winfuture`), dann nur Buchstaben/Ziffern. So matcht
/// „WinFuture" (Marke im Titel) gegen „winfuture.de" (Host als siteName).
private static func brandKey(_ value: String) -> String {
var v = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if v.hasPrefix("www.") { v = String(v.dropFirst(4)) }
if let dot = v.firstIndex(of: ".") { v = String(v[..<dot]) }
return v.filter { $0.isLetter || $0.isNumber }
}
}
/// Varianten-Ableitung für archivierte mana-media-URLs — Swift-Spiegel
/// von `apps/web/src/lib/media-url.ts`.
///
/// In Events/State steht immer die kanonische Original-URL
/// (`…/api/v1/media/<id>/file`). Die UI leitet Anzeige-Varianten per
/// Suffix ab (`/file/thumb`, `/file/medium`, `/file/large`) — mana-media
/// fällt bei fehlender Variante selbst aufs Original zurück. Fremde
/// URLs (Alt-Daten, Hotlinks aus der Prä-Archiv-Zeit) werden unverändert
/// durchgereicht.
enum MediaURL {
enum Variant: String {
case thumb, medium, large
}
static func variant(_ url: String, _ variant: Variant) -> String {
guard url.range(
of: #"/api/v1/media/[^/]+/file$"#,
options: .regularExpression
) != nil else { return url }
return "\(url)/\(variant.rawValue)"
}
}
/// Feed-Item aus dem mana-news-pool. Subset der Pool-CachedArticle-DTO
/// + `score` aus der server-side Personalisierung.
struct FeedItem: Codable, Sendable, Identifiable {
let id: String
let originalUrl: String
let title: String
let excerpt: String?
let content: String
let author: String?
let siteName: String
let sourceSlug: String
let imageUrl: String?
let topic: Topic
let language: String
let wordCount: Int?
let readingTimeMinutes: Int?
let publishedAt: String?
let ingestedAt: String
let score: Double?
}