- Lese-Fortschrittsbalken vom oberen an den unteren Bildschirmrand
(overlay .bottom + ignoresSafeArea(.bottom)), pinnt am physischen Rand
unter der schwebenden Tab-Bar.
- Article.displayTitle: schneidet einen redundanten Quell-/Marken-Suffix
("Headline – WinFuture", "… | heise online") vom extrahierten Titel.
Viele Seiten hängen ihre Marke an den <title>, den Readability (über
@mana/shared-rss) übernimmt — siteName steht in der UI aber separat
darüber. Konservativ: nur hinter typischem Trenner und nur wenn der
Schwanz per normalisiertem brandKey zur Quelle passt (WinFuture ↔
winfuture.de). Angewandt in Detail-Header, Listen-Row, Prev/Next.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
124 lines
4.3 KiB
Swift
124 lines
4.3 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: "Ungelesen"
|
||
case .reading: "Liest"
|
||
case .finished: "Gelesen"
|
||
case .archived: "Archiv"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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 }
|
||
}
|
||
}
|
||
|
||
/// 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?
|
||
}
|