From f71a9377c09951ba6a624203fa367aee654e5e1b Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 25 Apr 2026 14:33:43 +0200 Subject: [PATCH] feat(visibility): embed resolvers for memoro/cards/presi (M6 follow-on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the M6 loop — flipping a memo, card-deck, or presi-deck to 'public' now actually surfaces it on the owner's website embed. Previously M6 wired the Picker but the embed pipeline didn't know about these sources, so the flip had no visible effect. Three new sources in EmbedSourceSchema: - memoro.memos — voice-memo teaser. Title + intro (140 chars) + audio duration. Transcript, source-audio paths, and per-utterance speaker data stay private — those are the user's words verbatim with much stronger privacy weight than a curated headline. - cards.decks — flashcard-collection teaser. Name + "N Karten". Card fronts/backs, difficulty, review history all private — the deck is a unit; the cards belong to the play experience. - presi.decks — "talks I've given" teaser. Title + "N Folien" (counted by joining the slides table). Slide content stays private — the public deck is a pointer, the slides belong to the talk experience. Each resolver tolerates the M6 soft-migration window: visibility falls back to legacy isPublic for rows that haven't been re-saved since the M6 commit. Inspector dropdown updated to expose all 15 sources. Note: 3 unrelated svelte-check errors in data/seeds/wiring.test.ts (spaceId on LocalWorkbenchScene) from a parallel session. Not introduced here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/lib/modules/website/embeds.ts | 137 ++++++++++++++++++ .../ModuleEmbedInspectorFallback.svelte | 3 + .../website-blocks/src/moduleEmbed/schema.ts | 3 + 3 files changed, 143 insertions(+) diff --git a/apps/mana/apps/web/src/lib/modules/website/embeds.ts b/apps/mana/apps/web/src/lib/modules/website/embeds.ts index 8d6d19761..f104a1ad4 100644 --- a/apps/mana/apps/web/src/lib/modules/website/embeds.ts +++ b/apps/mana/apps/web/src/lib/modules/website/embeds.ts @@ -34,6 +34,9 @@ import type { LocalComicStory } from '$lib/modules/comic/types'; import type { LocalHabit, LocalHabitLog } from '$lib/modules/habits/types'; import type { LocalQuiz } from '$lib/modules/quiz/types'; import type { LocalSocialEvent } from '$lib/modules/events/types'; +import type { LocalMemo } from '$lib/modules/memoro/types'; +import type { LocalDeck as LocalCardDeck } from '$lib/modules/cards/types'; +import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; export interface ResolvedEmbed { @@ -84,6 +87,15 @@ export async function resolveEmbed(props: ModuleEmbedProps): Promise { + let memos = await db.table('memos').toArray(); + memos = memos.filter( + (m) => + !m.deletedAt && + !m.isArchived && + canEmbedOnWebsite(m.visibility ?? (m.isPublic === true ? 'public' : 'private')) + ); + + if (memos.length === 0) return []; + + const decrypted = (await decryptRecords('memos', memos)) as LocalMemo[]; + + // Pinned first, then newest. + decrypted.sort((a, b) => { + const pinA = a.isPinned ? 0 : 1; + const pinB = b.isPinned ? 0 : 1; + if (pinA !== pinB) return pinA - pinB; + return (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''); + }); + + function durationLabel(ms: number | null): string | null { + if (!ms || ms <= 0) return null; + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return s === 0 ? `${m} Min` : `${m}:${String(s).padStart(2, '0')} Min`; + } + + return decrypted.map((m) => { + const intro = (m.intro ?? '').trim().slice(0, 140); + const dur = durationLabel(m.audioDurationMs); + const subtitleParts = [intro || null, dur].filter((x): x is string => Boolean(x)); + return { + title: (m.title ?? '').trim() || 'Memo', + subtitle: subtitleParts.length > 0 ? subtitleParts.join(' · ') : undefined, + }; + }); +} + +/** + * Card-decks: shareable-flashcard-collection teaser. Returns decks + * flipped to 'public' with their card count as subtitle. + * + * Whitelist: title + "N Karten". Card fronts/backs, difficulty + * scores, and review history all stay private — the deck is a + * unit; its cards belong to the play-experience (future + * unlisted-share flow), not the public teaser. + */ +async function resolveCardDecks(_props: ModuleEmbedProps): Promise { + let decks = await db.table('cardDecks').toArray(); + decks = decks.filter( + (d) => + !d.deletedAt && + canEmbedOnWebsite(d.visibility ?? (d.isPublic === true ? 'public' : 'private')) + ); + + if (decks.length === 0) return []; + + const decrypted = (await decryptRecords('cardDecks', decks)) as LocalCardDeck[]; + + // Newest first. + decrypted.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); + + return decrypted.map((d) => { + const count = d.cardCount ?? 0; + return { + title: d.name, + subtitle: `${count} ${count === 1 ? 'Karte' : 'Karten'}`, + }; + }); +} + +/** + * Presi-decks: "talks I've given" teaser. Returns decks flipped to + * 'public' with their slide count as subtitle. + * + * Whitelist: title + "N Folien". Slide content (titles, body text, + * images, bullet points) all stay private — the public deck is a + * pointer the user can link from elsewhere; the actual slides + * belong to the talk experience. + */ +async function resolvePresiDecks(_props: ModuleEmbedProps): Promise { + let decks = await db.table('presiDecks').toArray(); + decks = decks.filter( + (d) => + !d.deletedAt && + canEmbedOnWebsite(d.visibility ?? (d.isPublic === true ? 'public' : 'private')) + ); + + if (decks.length === 0) return []; + + const decrypted = (await decryptRecords('presiDecks', decks)) as LocalPresiDeck[]; + + // Newest first. + decrypted.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? '')); + + const deckIds = decrypted.map((d) => d.id); + const slides = + deckIds.length > 0 ? await db.table('slides').where('deckId').anyOf(deckIds).toArray() : []; + const slideCountByDeck = new Map(); + for (const s of slides as Array<{ deckId: string; deletedAt?: string }>) { + if (s.deletedAt) continue; + slideCountByDeck.set(s.deckId, (slideCountByDeck.get(s.deckId) ?? 0) + 1); + } + + return decrypted.map((d) => { + const count = slideCountByDeck.get(d.id) ?? 0; + return { + title: d.title, + subtitle: `${count} ${count === 1 ? 'Folie' : 'Folien'}`, + }; + }); +} diff --git a/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte b/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte index af1849a7e..1887ca32e 100644 --- a/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte +++ b/packages/website-blocks/src/moduleEmbed/ModuleEmbedInspectorFallback.svelte @@ -28,6 +28,9 @@ + + + diff --git a/packages/website-blocks/src/moduleEmbed/schema.ts b/packages/website-blocks/src/moduleEmbed/schema.ts index 551dcde93..a4b1d6ec3 100644 --- a/packages/website-blocks/src/moduleEmbed/schema.ts +++ b/packages/website-blocks/src/moduleEmbed/schema.ts @@ -39,6 +39,9 @@ export const EmbedSourceSchema = z.enum([ 'habits.habits', 'quiz.quizzes', 'events.socialEvents', + 'memoro.memos', + 'cards.decks', + 'presi.decks', ]); export type EmbedSource = z.infer;