pageta-native/Sources/Core/Domain/Article.swift
Till JS 9f260e2bf4 feat: Fortschrittsbalken an unteren Rand + Quell-Suffix aus Titeln strippen
- 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>
2026-06-01 18:27:21 +02:00

124 lines
4.3 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: "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?
}