diff --git a/apps/cards/apps/web/src/lib/api/cards-api.ts b/apps/cards/apps/web/src/lib/api/cards-api.ts index 1f2b097a2..e871e5021 100644 --- a/apps/cards/apps/web/src/lib/api/cards-api.ts +++ b/apps/cards/apps/web/src/lib/api/cards-api.ts @@ -187,6 +187,28 @@ export const cardsApi = { method: 'DELETE', }), }, + subscriptions: { + list: () => request('/v1/me/subscriptions'), + subscribe: (deckSlug: string) => + request<{ deckSlug: string; latestVersionId: string }>( + `/v1/decks/${encodeURIComponent(deckSlug)}/subscribe`, + { method: 'POST' } + ), + unsubscribe: (deckSlug: string) => + request<{ ok: true }>(`/v1/decks/${encodeURIComponent(deckSlug)}/subscribe`, { + method: 'DELETE', + }), + version: (deckSlug: string, semver: string) => + request( + `/v1/decks/${encodeURIComponent(deckSlug)}/versions/${encodeURIComponent(semver)}`, + { auth: 'optional' } + ), + diff: (deckSlug: string, fromSemver: string) => + request( + `/v1/decks/${encodeURIComponent(deckSlug)}/diff?from=${encodeURIComponent(fromSemver)}`, + { auth: 'optional' } + ), + }, }; // Override author lookup to send token opportunistically — public reads. @@ -254,3 +276,39 @@ export interface PublishResult { version: PublicDeckVersion; moderation: { verdict: 'pass' | 'flag' | 'block'; categories: string[] }; } + +export interface SubscriptionInfo { + deckSlug: string; + deckTitle: string; + deckDescription: string | null; + subscribedAt: string; + notifyUpdates: boolean; + currentVersionId: string | null; + latestVersionId: string | null; + updateAvailable: boolean; +} + +export interface ServerCard { + contentHash: string; + type: string; + fields: Record; + ord: number; +} + +export interface DeckVersionPayload { + id: string; + semver: string; + contentHash: string; + publishedAt: string; + changelog: string | null; + cards: ServerCard[]; +} + +export interface DiffPayload { + from: string; + to: string; + added: ServerCard[]; + changed: { previous: { contentHash: string }; next: ServerCard }[]; + unchanged: { contentHash: string; ord: number }[]; + removed: { contentHash: string }[]; +} diff --git a/apps/cards/apps/web/src/lib/data/database.ts b/apps/cards/apps/web/src/lib/data/database.ts index 3a77d3391..48ec9a851 100644 --- a/apps/cards/apps/web/src/lib/data/database.ts +++ b/apps/cards/apps/web/src/lib/data/database.ts @@ -65,6 +65,12 @@ class CardsDatabase extends Dexie { deckTags: 'id, deckId, tagId', _pendingChanges: '++pk, table, queuedAt', }); + // v2 — Phase δ.2: index `subscribedFromSlug` on cardDecks so the + // subscribe service can lookup-by-slug to avoid duplicating + // subscriptions on re-pull. + this.version(2).stores({ + cardDecks: 'id, lastStudied, subscribedFromSlug', + }); } } diff --git a/apps/cards/apps/web/src/lib/services/subscribe.ts b/apps/cards/apps/web/src/lib/services/subscribe.ts new file mode 100644 index 000000000..cb9c58c55 --- /dev/null +++ b/apps/cards/apps/web/src/lib/services/subscribe.ts @@ -0,0 +1,145 @@ +/** + * Subscribe to a marketplace deck and pull its latest version into + * the local Dexie. Phase δ.2 — initial pull only; smart-merge of + * subsequent updates lands in δ.3 via `applySubscriptionUpdate` + * (placeholder export below). + * + * The subscribed deck shows up alongside own decks but is marked + * `subscribedFromSlug` + `subscribedAtVersion` so the UI can hide + * mutate controls and show an "Update available" indicator when + * cards-server reports a newer version. + */ + +import { cardsApi, CardsApiError, 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'; + +const ALLOWED_TYPES: CardType[] = [ + 'basic', + 'basic-reverse', + 'cloze', + 'type-in', + 'image-occlusion', + 'audio', + 'multiple-choice', +]; + +function asCardType(t: string): CardType { + return (ALLOWED_TYPES as string[]).includes(t) ? (t as CardType) : 'basic'; +} + +export interface SubscribeResult { + deckId: string; + cardCount: number; +} + +export async function subscribeAndPull(deckSlug: string): Promise { + // 1. Tell the server we're subscribed (idempotent, returns the + // version we should pull). + const sub = await cardsApi.subscriptions.subscribe(deckSlug); + + // 2. Fetch the deck metadata so we know title/description/etc. + const { deck, latestVersion } = await cardsApi.decks.bySlug(deckSlug); + if (!latestVersion) { + throw new Error('Subscribed but the deck has no published version yet'); + } + + // 3. Fetch the version's cards (full payload). + const version = await cardsApi.subscriptions.version(deckSlug, latestVersion.semver); + + // 4. Already subscribed locally? Don't duplicate — refresh in + // place. Phase δ.3 will swap this for a real diff-apply. + const existingDeck = await cardDeckTable + .where('subscribedFromSlug') + .equals(deckSlug) + .first() + .catch(() => undefined); + + const now = new Date().toISOString(); + const localDeck: LocalDeck = existingDeck ?? { + id: crypto.randomUUID(), + name: deck.title, + description: deck.description, + color: '#6366f1', + cardCount: version.cards.length, + visibility: 'private', + createdAt: now, + updatedAt: now, + subscribedFromSlug: deckSlug, + subscribedAtVersion: latestVersion.semver, + }; + + if (existingDeck) { + await cardDeckTable.update(existingDeck.id, { + name: deck.title, + description: deck.description, + cardCount: version.cards.length, + subscribedAtVersion: latestVersion.semver, + updatedAt: now, + }); + } else { + await cardDeckTable.add(localDeck); + } + + // 5. Replace cards (initial-pull strategy; δ.3 keeps FSRS state). + if (existingDeck) { + const oldCards = await cardTable.where('deckId').equals(existingDeck.id).toArray(); + for (const c of oldCards) { + if (!c.deletedAt) await cardTable.update(c.id, { deletedAt: now }); + } + } + + for (const sc of version.cards) { + const card: LocalCard = { + id: crypto.randomUUID(), + deckId: localDeck.id, + 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 ?? {}, + }); + } + + return { deckId: localDeck.id, cardCount: version.cards.length }; +} + +export async function unsubscribe(deckSlug: string): Promise { + await cardsApi.subscriptions.unsubscribe(deckSlug); + const local = await cardDeckTable + .where('subscribedFromSlug') + .equals(deckSlug) + .first() + .catch(() => undefined); + if (!local) return; + const now = new Date().toISOString(); + const cards = await cardTable.where('deckId').equals(local.id).toArray(); + for (const c of cards) { + if (!c.deletedAt) await cardTable.update(c.id, { deletedAt: now }); + } + await cardDeckTable.update(local.id, { deletedAt: now }); +} + +/** Helper: am I already subscribed locally to this slug? */ +export async function isSubscribedLocally(slug: string): Promise { + try { + const row = await cardDeckTable.where('subscribedFromSlug').equals(slug).first(); + return Boolean(row && !row.deletedAt); + } catch { + return false; + } +} + +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). +} diff --git a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte index f1809e55a..2de50f830 100644 --- a/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte +++ b/apps/cards/apps/web/src/routes/d/[slug]/+page.svelte @@ -1,24 +1,27 @@ @@ -80,7 +103,9 @@ {#if stage === 'loading'}

Lade Deck…

{:else if stage === 'not-found'} -

+

Deck {slug} existiert nicht.

{:else if stage === 'error'} @@ -128,27 +153,52 @@ - + + {#if subscribed} + + {#if subscribedDeckId} + + {/if} + {:else} + + {/if} {:else} - Anmelden um zu merken + Anmelden um zu abonnieren {/if} + {#if error} +

{error}

+ {/if} +

Veröffentlicht: {new Date(deck.createdAt).toLocaleDateString('de-DE')}

diff --git a/packages/cards-core/src/types.ts b/packages/cards-core/src/types.ts index 7b32a405b..6d29118f5 100644 --- a/packages/cards-core/src/types.ts +++ b/packages/cards-core/src/types.ts @@ -46,6 +46,18 @@ export interface LocalDeck extends BaseRecord { visibilityChangedAt?: string; visibilityChangedBy?: string; activeStudyBlockId?: string | null; + + /** + * Marketplace-subscription markers. Set on decks that the user + * pulled from cards.mana.how/d/ rather than created + * themselves. The pair (slug + version) lets the client compute + * a smart-merge diff against the server's latest version. + * + * Subscribed decks are read-only locally — the editor hides its + * mutate buttons. Forking instead makes a separate own-deck row. + */ + subscribedFromSlug?: string; + subscribedAtVersion?: string; } export interface LocalCard extends BaseRecord { @@ -61,6 +73,15 @@ export interface LocalCard extends BaseRecord { difficulty?: number; nextReview?: string | null; reviewCount?: number; + + /** + * For cards pulled from a marketplace subscription: the server- + * computed SHA-256 of (type, fields). Powers smart-merge — when + * an updated version arrives, cards whose hash matches keep their + * FSRS state; cards whose hash changes get refreshed content but + * also keep their FSRS state (better for the learner than a reset). + */ + serverContentHash?: string; } /**