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>
180 lines
5.5 KiB
Swift
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
|
|
}
|
|
}
|