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>
147 lines
5.2 KiB
Swift
147 lines
5.2 KiB
Swift
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?
|
||
}
|