diff --git a/apps/cards/apps/web/src/lib/data/database.ts b/apps/cards/apps/web/src/lib/data/database.ts index 48ec9a851..7088f15e1 100644 --- a/apps/cards/apps/web/src/lib/data/database.ts +++ b/apps/cards/apps/web/src/lib/data/database.ts @@ -71,6 +71,13 @@ class CardsDatabase extends Dexie { this.version(2).stores({ cardDecks: 'id, lastStudied, subscribedFromSlug', }); + // v3 — Phase δ.3: compound index on (deckId, serverContentHash) + // for the smart-merge lookup. Diff payloads reference cards by + // their content hash; we need O(1) lookups per (deck, hash) to + // classify each diff entry against local rows. + this.version(3).stores({ + cards: 'id, deckId, order, [deckId+order], [deckId+serverContentHash]', + }); } } diff --git a/apps/cards/apps/web/src/lib/services/subscribe.ts b/apps/cards/apps/web/src/lib/services/subscribe.ts index cb9c58c55..24c179fb8 100644 --- a/apps/cards/apps/web/src/lib/services/subscribe.ts +++ b/apps/cards/apps/web/src/lib/services/subscribe.ts @@ -10,7 +10,8 @@ * cards-server reports a newer version. */ -import { cardsApi, CardsApiError, type ServerCard } from '$lib/api/cards-api'; +import { cardsApi, CardsApiError } from '$lib/api/cards-api'; +import type { ServerCard } from '$lib/api/cards-api'; import { cardDeckTable, cardTable } from '$lib/data/database'; import { reviewStore } from '$lib/stores/reviews.svelte'; import type { CardType, LocalCard, LocalDeck } from '@mana/cards-core'; @@ -138,8 +139,176 @@ export async function isSubscribedLocally(slug: string): Promise { } } -export async function ensureSubscriptionFresh(_deckSlug: string): Promise { - // Placeholder for Phase δ.3 — when an update is available, fetch - // the diff and apply it card-by-card without touching FSRS state. - // Today we just re-pull (existingDeck branch above does that). +export interface UpdatePreview { + from: string; + to: string; + added: number; + changed: number; + removed: number; + unchanged: number; +} + +/** + * Compute what would change if we pulled the latest version. Returns + * `null` if already on latest. Used by the deck-detail banner so the + * user sees "X neue, Y geänderte, Z entfernte" before committing. + */ +export async function previewUpdate(deckSlug: string): Promise { + const local = await cardDeckTable + .where('subscribedFromSlug') + .equals(deckSlug) + .first() + .catch(() => undefined); + if (!local || local.deletedAt || !local.subscribedAtVersion) return null; + const diff = await cardsApi.subscriptions.diff(deckSlug, local.subscribedAtVersion); + if (diff.from === diff.to) return null; + return { + from: diff.from, + to: diff.to, + added: diff.added.length, + changed: diff.changed.length, + removed: diff.removed.length, + unchanged: diff.unchanged.length, + }; +} + +/** + * Smart-merge the latest server version into the local Dexie copy + * without losing FSRS state. + * + * - **unchanged**: leave the local card alone — its FSRS reviews + * stay attached and the learning schedule continues unbroken. + * - **changed**: lookup local card by previous-hash, update fields/ + * type/order/serverContentHash to the new values. FSRS reviews + * stay attached because we don't touch the card id. Re-runs + * ensureReviewsForCard so cloze-cluster fan-out matches the new + * content. + * - **added**: insert a new card with fresh FSRS reviews. + * - **removed**: soft-delete by content-hash + cascade reviews. + * + * Final step: bump local subscribedAtVersion + re-stamp server-side + * (POST /subscribe is idempotent and re-anchors the user's row). + */ +export async function applyUpdate(deckSlug: string): Promise { + const local = await cardDeckTable + .where('subscribedFromSlug') + .equals(deckSlug) + .first() + .catch(() => undefined); + if (!local || local.deletedAt || !local.subscribedAtVersion) return null; + + const diff = await cardsApi.subscriptions.diff(deckSlug, local.subscribedAtVersion); + if (diff.from === diff.to) return null; + + const now = new Date().toISOString(); + + for (const r of diff.removed) { + const localCard = await cardTable + .where('[deckId+serverContentHash]') + .equals([local.id, r.contentHash]) + .first(); + if (localCard && !localCard.deletedAt) { + await cardTable.update(localCard.id, { deletedAt: now }); + await reviewStore.softDeleteForCard(localCard.id); + } + } + + for (const c of diff.changed) { + const localCard = await cardTable + .where('[deckId+serverContentHash]') + .equals([local.id, c.previous.contentHash]) + .first(); + if (!localCard) { + // Heuristic mismatch — treat as added. + await insertSubscribedCard(local.id, c.next, now); + continue; + } + const nextType = asCardType(c.next.type); + await cardTable.update(localCard.id, { + type: nextType, + fields: c.next.fields, + order: c.next.ord, + serverContentHash: c.next.contentHash, + updatedAt: now, + }); + await reviewStore.ensureReviewsForCard({ + id: localCard.id, + type: nextType, + fields: c.next.fields, + }); + } + + for (const a of diff.added) { + await insertSubscribedCard(local.id, a, now); + } + + for (const u of diff.unchanged) { + const localCard = await cardTable + .where('[deckId+serverContentHash]') + .equals([local.id, u.contentHash]) + .first(); + if (localCard && localCard.order !== u.ord) { + await cardTable.update(localCard.id, { order: u.ord, updatedAt: now }); + } + } + + const liveCards = await cardTable.where('deckId').equals(local.id).toArray(); + const liveCount = liveCards.filter((c) => !c.deletedAt).length; + await cardDeckTable.update(local.id, { + subscribedAtVersion: diff.to, + cardCount: liveCount, + updatedAt: now, + }); + + try { + await cardsApi.subscriptions.subscribe(deckSlug); + } catch { + // Idempotent server-side; if this fails the local pointer + // already advanced and the next sync will reconcile. + } + + return { + from: diff.from, + to: diff.to, + added: diff.added.length, + changed: diff.changed.length, + removed: diff.removed.length, + unchanged: diff.unchanged.length, + }; +} + +async function insertSubscribedCard(deckId: string, sc: ServerCard, now: string): Promise { + const card: LocalCard = { + id: crypto.randomUUID(), + deckId, + type: asCardType(sc.type), + fields: sc.fields, + order: sc.ord, + serverContentHash: sc.contentHash, + createdAt: now, + updatedAt: now, + }; + await cardTable.add(card); + await reviewStore.ensureReviewsForCard({ + id: card.id, + type: card.type as CardType, + fields: card.fields ?? {}, + }); +} + +/** + * One-shot poll of the user's subscriptions to see which decks have + * a newer version waiting. Powers the dashboard "Updates"-banner. + */ +export async function listSubscriptionUpdates(): Promise<{ slug: string; title: string }[]> { + let subs; + try { + subs = await cardsApi.subscriptions.list(); + } catch (e) { + if (e instanceof CardsApiError && e.status === 401) return []; + throw e; + } + return subs + .filter((s) => s.updateAvailable) + .map((s) => ({ slug: s.deckSlug, title: s.deckTitle })); } diff --git a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte index b6183ef4d..48fe8c933 100644 --- a/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte +++ b/apps/cards/apps/web/src/routes/decks/[id]/+page.svelte @@ -8,6 +8,8 @@ import AiCardGen from '$lib/components/AiCardGen.svelte'; import PublishDeckModal from '$lib/components/PublishDeckModal.svelte'; import { uploadCardMedia, mediaToFieldSnippet } from '$lib/media/upload'; + import { cardDeckTable } from '$lib/data/database'; + import { previewUpdate, applyUpdate, type UpdatePreview } from '$lib/services/subscribe'; const deckId = $derived(page.params.id as string); @@ -22,6 +24,51 @@ let showNew = $state(false); let showAi = $state(false); let showPublish = $state(false); + + // Subscription state — populated on mount + after each change so + // the read-only gating + update-banner stays in sync without + // hooking another live-query. + let subscribedFromSlug = $state(null); + let subscribedAtVersion = $state(null); + let updatePreview = $state(null); + let updateBusy = $state(false); + let updateError = $state(null); + + const isSubscribed = $derived(subscribedFromSlug !== null); + + $effect(() => { + if (!deckId) return; + void refreshSubscriptionState(); + }); + + async function refreshSubscriptionState() { + const local = await cardDeckTable.get(deckId).catch(() => undefined); + subscribedFromSlug = local?.subscribedFromSlug ?? null; + subscribedAtVersion = local?.subscribedAtVersion ?? null; + if (!subscribedFromSlug) { + updatePreview = null; + return; + } + try { + updatePreview = await previewUpdate(subscribedFromSlug); + } catch { + updatePreview = null; + } + } + + async function handleApplyUpdate() { + if (!subscribedFromSlug || updateBusy) return; + updateBusy = true; + updateError = null; + try { + await applyUpdate(subscribedFromSlug); + await refreshSubscriptionState(); + } catch (e) { + updateError = e instanceof Error ? e.message : 'Update fehlgeschlagen'; + } finally { + updateBusy = false; + } + } let attachBusy = $state<'front' | 'back' | 'cloze' | null>(null); let attachError = $state(null); let attachInputs = $state>({ @@ -161,6 +208,46 @@ + {#if isSubscribed} +
+
+
+
+ 📥 Abonniert · v{subscribedAtVersion} +
+

+ Aus dem Marktplatz von {subscribedFromSlug}. Karten sind read-only — Author entscheidet über Inhalte. Forken um eigene Variante + zu machen (Phase ε). +

+
+
+ {#if updatePreview} +
+ + Update auf v{updatePreview.to} verfügbar + + + +{updatePreview.added} neu · ~{updatePreview.changed} geändert · −{updatePreview.removed} + entfernt + + +
+ {/if} + {#if updateError} +

{updateError}

+ {/if} +
+ {/if} +
- + {#if !isSubscribed} + + {/if} {#if dueCount === 0 && cards.length > 0} Heute alles gelernt — schau später wieder rein. @@ -201,20 +290,22 @@
-
- - -
+ {#if !isSubscribed} +
+ + +
+ {/if} {#if showAi}
@@ -389,13 +480,15 @@ {typeBadge(card.type)} - + {#if !isSubscribed} + + {/if}
{/each}