pageta-native/Sources/Core/EventSync/Reducers.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

180 lines
5.5 KiB
Swift

import Foundation
import ManaEventSync
// Reducer-Funktionen: replay über Events liefert den projezierten State.
// Swift-Spiegel der `emit`-Logik in `apps/web/src/lib/event-sync-api.ts`
// — was dort als Event-Type emittiert wird, muss hier reduziert werden.
//
// Reducer sind idempotent gegen Replay: gleicher Event-Stream → gleicher
// State. Bei unbekanntem eventType wird übersprungen statt Crash
// (Forward-Kompatibilität).
enum Reducers {
// MARK: - Article
static func replayArticle(_ events: [EventEnvelope]) -> ArticleState? {
guard !events.isEmpty else { return nil }
var state = ArticleState.initial()
for event in events {
state = applyArticle(state, event)
}
// Vor ArticleSaved existiert das Aggregat semantisch nicht.
return state.id.isEmpty ? nil : state
}
private static func applyArticle(
_ state: ArticleState,
_ event: EventEnvelope
) -> ArticleState {
var s = state
switch event.eventType {
case EventType.articleSaved:
guard let p = try? event.payload.decode(ArticleSavedPayload.self) else { return s }
s.id = p.articleId
s.userId = event.attributedToUserId
s.originalUrl = p.originalUrl
s.urlHash = p.urlHash
s.title = p.title
s.excerpt = p.excerpt
s.content = p.content
s.author = p.author
s.siteName = p.siteName
s.imageUrl = p.imageUrl
s.wordCount = p.wordCount
s.readingTimeMinutes = p.readingTimeMinutes
s.publishedAt = p.publishedAt
s.status = .unread
s.readingProgress = 0
s.isFavorite = false
s.userNote = p.userNote
s.tags = []
s.savedAt = event.occurredAt
s.readAt = nil
s.deletedAt = nil
s.warning = nil
case EventType.articleEnriched:
// Deferred extraction: füllt Inhalt/Metadaten nach. Bewusst
// non-destruktiv — Status/Progress/Favorit/Note/Tags/savedAt
// bleiben, wie der User sie zwischenzeitlich gesetzt hat.
guard let p = try? event.payload.decode(ArticleEnrichedPayload.self) else { return s }
s.title = p.title
s.excerpt = p.excerpt
s.content = p.content
s.author = p.author
s.siteName = p.siteName
// imageUrl nur überschreiben, wenn das Event eins trägt — sonst
// clobbert ein Enrich (imageUrl: nil) das bereits archivierte
// Bild aus ArticleMediaArchived (Spiegel des `?? state`-Patterns
// im shared-schemas-Reducer, wie bei `warning`).
if let imageUrl = p.imageUrl { s.imageUrl = imageUrl }
s.wordCount = p.wordCount
s.readingTimeMinutes = p.readingTimeMinutes
s.publishedAt = p.publishedAt
if let warning = p.warning { s.warning = warning }
case EventType.articleStatusChanged:
guard let p = try? event.payload.decode(ArticleStatusChangedPayload.self)
else { return s }
s.status = p.newStatus
if p.newStatus == .finished, s.readAt == nil {
s.readAt = event.occurredAt
}
case EventType.articleProgressUpdated:
guard let p = try? event.payload.decode(ArticleProgressUpdatedPayload.self)
else { return s }
s.readingProgress = p.newProgress
case EventType.articleFavoriteToggled:
guard let p = try? event.payload.decode(ArticleFavoriteToggledPayload.self)
else { return s }
s.isFavorite = p.isFavorite
case EventType.articleNoteUpdated:
guard let p = try? event.payload.decode(ArticleNoteUpdatedPayload.self)
else { return s }
s.userNote = p.newNote
case EventType.articleTagsSet:
guard let p = try? event.payload.decode(ArticleTagsSetPayload.self) else { return s }
s.tags = p.tags
case EventType.articleWarningFlagged:
guard let p = try? event.payload.decode(ArticleWarningFlaggedPayload.self)
else { return s }
s.warning = p.warning
case EventType.articleMediaArchived:
// Non-destruktiv (`?? state`-Semantik wie im shared-schemas-
// Reducer): ein imageUrl-only-Archiv (Native) leert kein
// früheres Voll-Archiv (Web mit htmlContent) — und umgekehrt.
guard let p = try? event.payload.decode(ArticleMediaArchivedPayload.self)
else { return s }
if let htmlContent = p.htmlContent { s.htmlContent = htmlContent }
if let imageUrl = p.imageUrl { s.imageUrl = imageUrl }
case EventType.articleWarningCleared:
s.warning = nil
case EventType.articleDeleted:
s.deletedAt = event.occurredAt
default:
// Unbekannter Event-Type: skippen.
break
}
return s
}
// MARK: - Highlight
static func replayHighlight(_ events: [EventEnvelope]) -> HighlightEventState? {
guard !events.isEmpty else { return nil }
var state = HighlightEventState.initial()
for event in events {
state = applyHighlight(state, event)
}
return state.id.isEmpty ? nil : state
}
private static func applyHighlight(
_ state: HighlightEventState,
_ event: EventEnvelope
) -> HighlightEventState {
var s = state
switch event.eventType {
case EventType.highlightCreated:
guard let p = try? event.payload.decode(HighlightCreatedPayload.self) else { return s }
s.id = p.highlightId
s.userId = event.attributedToUserId
s.articleId = p.articleId
s.text = p.text
s.note = p.note
s.color = p.color
s.startOffset = p.startOffset
s.endOffset = p.endOffset
s.contextBefore = p.contextBefore
s.contextAfter = p.contextAfter
s.createdAt = event.occurredAt
s.deletedAt = nil
case EventType.highlightNoteUpdated:
guard let p = try? event.payload.decode(HighlightNoteUpdatedPayload.self)
else { return s }
s.note = p.newNote
case EventType.highlightColorChanged:
guard let p = try? event.payload.decode(HighlightColorChangedPayload.self)
else { return s }
s.color = p.newColor
case EventType.highlightDeleted:
s.deletedAt = event.occurredAt
default:
break
}
return s
}
}