mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
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:
parent
58c057f6c5
commit
521ae52a62
3 changed files with 305 additions and 36 deletions
|
|
@ -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]',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue