pageta-native/Sources/Core/EventSync/Reducers.swift
Till JS 6ec9e19dca Geteilte Links local-first speichern statt Login-Gate
Korrigiert den vorigen Ansatz: extract/preview ist server-auth-gated, ein
ausgeloggt geteilter Link konnte deshalb nicht gespeichert werden und
erschien erst nach Login. Statt einen Anmelde-Hinweis zu zeigen, speichert
Pageta den Link jetzt local-first.

- flushPendingShares: kein .signedIn-Gate mehr. saveSharedArticle legt einen
  Stub an (ArticleSaved, Host-Titel, leerer Inhalt) — reiner lokaler Emit,
  funktioniert anonym. Artikel erscheint sofort in der Lese-Liste.
- Neuer Event-Typ ArticleEnriched (+ non-destruktiver Reducer-Case) füllt
  Titel/Inhalt/Metadaten nach, ohne Status/Progress/Favorit/Notiz/Tags zu
  berühren.
- enrichPendingArticles-Sweep: läuft nach Flush, bei App-Active und Login;
  pro Artikel best-effort, gescheiterte Versuche je Session nicht wiederholt.
  claimOnSignIn liftet die anonymen Events beim Login in den Account.
- ShareExtension-Toast wieder ehrlich "Zur Lese-Liste hinzugefügt"; das nun
  überflüssige SharedSessionFlag + der Login-Prompt entfernt.
- Reader zeigt für noch nicht extrahierte Artikel einen "Inhalt wird noch
  geladen"-Hinweis statt einer leeren Seite.

Offener Punkt: Web-Reducer kennt ArticleEnriched noch nicht -> auf Web
bleiben so geteilte Artikel bis zum Web-Follow-up inhaltsleer.

Builds: iOS grün, macOS kompiliert (nur Provisioning blockt Signing).
Tests: 38/38 grün.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 15:50:47 +02:00

167 lines
4.8 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
s.imageUrl = p.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.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
}
}