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>
167 lines
4.8 KiB
Swift
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
|
|
}
|
|
}
|