feat(cards-web): Phase δ.3 — Smart-merge updates + read-only subscribed decks

Closes the marketplace subscribe loop. When the author publishes a
new version of a subscribed deck, the user sees a compact banner
with a per-card breakdown and one-click "Update anwenden" — FSRS
learning state survives across the merge.

  - lib/services/subscribe.ts:
    - previewUpdate(slug): server diff /v1/decks/:slug/diff?from=…
      with a small UpdatePreview shape ({added, changed, removed,
      unchanged} as counts).
    - applyUpdate(slug):
      - removed → soft-delete by [deckId+serverContentHash], cascade
        reviewStore.softDeleteForCard.
      - changed → update local card in place; ensureReviewsForCard
        re-runs to pick up cloze-cluster changes. FSRS reviews stay
        attached because the card-id never changes.
      - added → fresh local card + fresh FSRS reviews.
      - unchanged → only re-stamp ord if the diff moved it.
      Bumps subscribedAtVersion locally + re-stamps server-side.
  - lib/data/database.ts: Dexie v3 adds compound index
    [deckId+serverContentHash] for the smart-merge lookup.
  - routes/decks/[id]/+page.svelte:
    - subscribed decks get a green "📥 Abonniert"-banner with the
      version stamp + a deep-link to the public marketplace page.
    - if previewUpdate returns a non-null diff, the banner inlines
      the counts and an "Update anwenden" button.
    - "Veröffentlichen", "Neue Karte", "Aus Text generieren" and
      the per-card delete buttons are hidden when the deck is
      subscribed — read-only mirror of the author's content.

Validated: svelte-check 0/0, vite build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-05-07 20:21:32 +02:00
parent 58c057f6c5
commit 521ae52a62
3 changed files with 305 additions and 36 deletions

View file

@ -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]',
});
}
}

View file

@ -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<boolean> {
}
}
export async function ensureSubscriptionFresh(_deckSlug: string): Promise<void> {
// 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<UpdatePreview | null> {
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<UpdatePreview | null> {
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<void> {
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 }));
}

View file

@ -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<string | null>(null);
let subscribedAtVersion = $state<string | null>(null);
let updatePreview = $state<UpdatePreview | null>(null);
let updateBusy = $state(false);
let updateError = $state<string | null>(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<string | null>(null);
let attachInputs = $state<Record<string, HTMLInputElement | null>>({
@ -161,6 +208,46 @@
</button>
</header>
{#if isSubscribed}
<div class="mb-6 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4 text-sm">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium text-emerald-300">
📥 Abonniert · v{subscribedAtVersion}
</div>
<p class="mt-1 text-xs text-neutral-400">
Aus dem Marktplatz von <a
href={`/d/${subscribedFromSlug}`}
class="text-emerald-300 hover:underline">{subscribedFromSlug}</a
>. Karten sind read-only — Author entscheidet über Inhalte. Forken um eigene Variante
zu machen (Phase ε).
</p>
</div>
</div>
{#if updatePreview}
<div class="mt-3 flex flex-wrap items-center gap-2 rounded-lg bg-emerald-500/10 p-2">
<span class="text-xs font-medium text-emerald-200">
Update auf v{updatePreview.to} verfügbar
</span>
<span class="text-xs text-neutral-400">
+{updatePreview.added} neu · ~{updatePreview.changed} geändert · {updatePreview.removed}
entfernt
</span>
<button
class="ml-auto rounded-lg bg-emerald-500 px-3 py-1 text-xs text-white hover:bg-emerald-400 disabled:opacity-50"
onclick={handleApplyUpdate}
disabled={updateBusy}
>
{updateBusy ? 'Wende an…' : 'Update anwenden'}
</button>
</div>
{/if}
{#if updateError}
<p class="mt-2 text-xs text-red-400">{updateError}</p>
{/if}
</div>
{/if}
<div class="mb-6 flex flex-wrap items-center gap-3">
<button
class="rounded-lg bg-indigo-500 px-5 py-2.5 text-sm font-medium text-white hover:bg-indigo-400 disabled:opacity-50"
@ -174,16 +261,18 @@
</span>
{/if}
</button>
<button
class="rounded-lg border border-indigo-500/30 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10 disabled:opacity-50"
onclick={() => (showPublish = true)}
disabled={cards.length === 0}
title={cards.length === 0
? 'Erstelle zuerst Karten'
: 'Im Cards-Marktplatz veröffentlichen'}
>
🌍 Veröffentlichen
</button>
{#if !isSubscribed}
<button
class="rounded-lg border border-indigo-500/30 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10 disabled:opacity-50"
onclick={() => (showPublish = true)}
disabled={cards.length === 0}
title={cards.length === 0
? 'Erstelle zuerst Karten'
: 'Im Cards-Marktplatz veröffentlichen'}
>
🌍 Veröffentlichen
</button>
{/if}
{#if dueCount === 0 && cards.length > 0}
<span class="text-sm text-neutral-400">Heute alles gelernt — schau später wieder rein.</span
>
@ -201,20 +290,22 @@
</div>
</div>
<div class="mb-6 flex flex-wrap items-center gap-3">
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
onclick={() => (showNew = true)}
>
Neue Karte
</button>
<button
class="rounded-lg border border-indigo-500/30 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10"
onclick={() => (showAi = !showAi)}
>
✨ Aus Text generieren
</button>
</div>
{#if !isSubscribed}
<div class="mb-6 flex flex-wrap items-center gap-3">
<button
class="rounded-lg bg-indigo-500 px-4 py-2 text-sm text-white hover:bg-indigo-400"
onclick={() => (showNew = true)}
>
Neue Karte
</button>
<button
class="rounded-lg border border-indigo-500/30 px-4 py-2 text-sm text-indigo-300 hover:bg-indigo-500/10"
onclick={() => (showAi = !showAi)}
>
✨ Aus Text generieren
</button>
</div>
{/if}
{#if showAi}
<div class="mb-6">
@ -389,13 +480,15 @@
<span class="rounded-full bg-neutral-800 px-2 py-0.5 text-xs text-neutral-400">
{typeBadge(card.type)}
</span>
<button
class="rounded p-1 text-neutral-500 hover:text-red-400"
onclick={() => handleDeleteCard(card.id)}
aria-label="Karte löschen"
>
</button>
{#if !isSubscribed}
<button
class="rounded p-1 text-neutral-500 hover:text-red-400"
onclick={() => handleDeleteCard(card.id)}
aria-label="Karte löschen"
>
</button>
{/if}
</div>
</li>
{/each}