feat(visibility): M6 soft-migrate isPublic→visibility on memoro/cards/presi/uload

Brings four legacy isPublic-only modules onto the unified visibility
system. The Picker (private/space/public) replaces the ad-hoc
boolean toggle in memoro + cards + presi DetailViews. Each store
now mirrors `visibility ↔ isPublic` on every flip so older readers
(search index, snapshot pipelines, sync server) keep working
through the soak window — M6.1 will hard-drop the legacy field
once the field has propagated to all rows.

Per-module:
- memoro: setVisibility on memosStore + Picker in DetailView
  properties row. Picker reads the unified `visibility` with a
  fallback to legacy isPublic.
- cards (decks): replaces the "Öffentlich Ja/Nein" toggle button
  with the Picker. createDeck initializes both fields; updateDeck
  mirrors when callers pass legacy isPublic.
- presi (decks): same pattern as cards. setVisibility added to the
  store factory's exported surface.
- uload (tags): no active CRUD UI, so this is type-only soft-migrate
  + seed-data update. Future tag-management view writes visibility
  directly. No store mutation method needed yet.

Out of scope (intentional):
- picture isPublic hard-drop: deferred. Picture has been on
  visibility since M3 (commit 0e9f574df), the soak window is mature
  enough to consider, but pre-launch there's no urgency. Defer to
  M6.1 with the rest of the legacy-field cleanup.
- events isPublished/publicToken: STAYS. This isn't legacy — it's
  the orthogonal mana-events RSVP-snapshot system. visibility
  controls website-embed eligibility; isPublished controls RSVP
  page existence. Different concerns; both stay.

Picker hides 'unlisted' for all four (no server-publish-snapshot
flow wired for these collections — same pattern as habits/quiz/events).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-25 14:11:07 +02:00
parent bd559e739e
commit ad5987f1dd
15 changed files with 192 additions and 32 deletions

View file

@ -18,7 +18,8 @@ export function toDeck(local: LocalDeck): Deck {
title: local.name,
description: local.description ?? undefined,
color: local.color,
isPublic: local.isPublic,
isPublic: local.isPublic ?? local.visibility === 'public',
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'),
tags: [],
cardCount: local.cardCount,
createdAt: local.createdAt ?? new Date().toISOString(),

View file

@ -10,6 +10,10 @@ import { db } from '$lib/data/database';
import { cardDeckTable, cardTable } from '../collections';
import { toDeck } from '../queries';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import { defaultVisibilityFor, type VisibilityLevel } from '@mana/shared-privacy';
import { createBlock, updateBlock } from '$lib/data/time-blocks/service';
import type { LocalDeck } from '../types';
import type { Deck, CreateDeckInput, UpdateDeckInput } from '../types';
@ -24,13 +28,17 @@ export const deckStore = {
async createDeck(input: CreateDeckInput): Promise<Deck | null> {
error = null;
try {
const initialPublic = input.isPublic ?? false;
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
name: input.title,
description: input.description ?? null,
color: '#6366f1',
cardCount: 0,
isPublic: input.isPublic ?? false,
isPublic: initialPublic,
// Initialize the unified field too — if the create flow set
// isPublic, mirror it as 'public'; otherwise space-default.
visibility: initialPublic ? 'public' : defaultVisibilityFor(getActiveSpace()?.type),
};
const plaintextSnapshot = toDeck(newLocal);
@ -51,7 +59,12 @@ export const deckStore = {
const localUpdates: Partial<LocalDeck> = {};
if (updates.title !== undefined) localUpdates.name = updates.title;
if (updates.description !== undefined) localUpdates.description = updates.description;
if (updates.isPublic !== undefined) localUpdates.isPublic = updates.isPublic;
if (updates.isPublic !== undefined) {
// Legacy callers still pass isPublic — mirror to visibility
// so the unified field stays in sync until M6.1 hard-drop.
localUpdates.isPublic = updates.isPublic;
localUpdates.visibility = updates.isPublic ? 'public' : 'space';
}
const diff: Partial<LocalDeck> = {
...localUpdates,
@ -65,6 +78,34 @@ export const deckStore = {
}
},
/**
* Flip a deck's visibility. M6 soft-migration: writes both
* `visibility` and the legacy `isPublic` mirror so the picker
* coexists with the older "public" badge UI until M6.1 hard-drop.
*/
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await cardDeckTable.get(id);
if (!existing) throw new Error(`Deck ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? (existing.isPublic ? 'public' : 'space');
if (before === next) return;
const stamp = new Date().toISOString();
await cardDeckTable.update(id, {
visibility: next,
isPublic: next === 'public',
visibilityChangedAt: stamp,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: stamp,
});
emitDomainEvent('VisibilityChanged', 'cards', 'cardDecks', id, {
recordId: id,
collection: 'cardDecks',
before,
after: next,
});
},
async deleteDeck(id: string) {
error = null;
try {

View file

@ -3,6 +3,7 @@
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
export interface LocalDeck extends BaseRecord {
name: string;
@ -10,7 +11,11 @@ export interface LocalDeck extends BaseRecord {
color: string;
cardCount: number;
lastStudied?: string | null;
/** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */
isPublic: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
activeStudyBlockId?: string | null;
}
@ -31,7 +36,9 @@ export interface Deck {
title: string;
description?: string;
color: string;
/** @deprecated Use `visibility`. */
isPublic: boolean;
visibility: VisibilityLevel;
tags: string[];
cardCount: number;
createdAt: string;

View file

@ -9,6 +9,7 @@
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { deckStore } from '../stores/decks.svelte';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import type { ViewProps } from '$lib/app-registry';
import type { LocalDeck, LocalCard } from '../types';
@ -18,7 +19,6 @@
let editName = $state('');
let editDescription = $state('');
let editColor = $state('#6366f1');
let editIsPublic = $state(false);
const detail = useDetailEntity<LocalDeck>({
id: () => deckId,
@ -27,7 +27,6 @@
editName = val.name;
editDescription = val.description ?? '';
editColor = val.color ?? '#6366f1';
editIsPublic = val.isPublic;
},
});
@ -51,7 +50,6 @@
await deckStore.updateDeck(deckId, {
title: editName.trim() || detail.entity?.name || 'Unbenannt',
description: editDescription.trim() || undefined,
isPublic: editIsPublic,
});
// Color is not in UpdateDeckInput, update directly
await db.table('decks').update(deckId, {
@ -59,11 +57,6 @@
updatedAt: new Date().toISOString(),
});
}
async function handlePublicToggle() {
editIsPublic = !editIsPublic;
await deckStore.updateDeck(deckId, { isPublic: editIsPublic });
}
</script>
<DetailViewShell
@ -103,10 +96,12 @@
</div>
<div class="prop-row">
<span class="prop-label">Öffentlich</span>
<button class="toggle-btn" class:active={editIsPublic} onclick={handlePublicToggle}>
{editIsPublic ? 'Ja' : 'Nein'}
</button>
<span class="prop-label">Sichtbarkeit</span>
<VisibilityPicker
level={deck.visibility ?? (deck.isPublic ? 'public' : 'space')}
onChange={(next: VisibilityLevel) => deckStore.setVisibility(deckId, next)}
disabledLevels={['unlisted']}
/>
</div>
<div class="prop-row">

View file

@ -35,7 +35,12 @@ export function toMemo(local: LocalMemo): Memo {
processingStatus: local.processingStatus,
isArchived: local.isArchived,
isPinned: local.isPinned,
isPublic: local.isPublic,
isPublic: local.isPublic ?? local.visibility === 'public',
// Soft-fallback during M6 soak: legacy rows use isPublic, new
// writes set visibility directly. Keep both in sync via the
// store's setVisibility/setPublic methods until M6.1 drops the
// legacy field.
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'),
language: local.language,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),

View file

@ -11,6 +11,9 @@ import { createArchiveOps } from '@mana/shared-stores';
import { MemoroEvents } from '@mana/shared-utils/analytics';
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import { defaultVisibilityFor, type VisibilityLevel } from '@mana/shared-privacy';
import { transcribeAudio } from '$lib/voice/transcribe';
import { llmTaskQueue } from '$lib/llm-queue';
import { generateTitleTask } from '$lib/llm-tasks/generate-title';
@ -43,6 +46,7 @@ export const memosStore = {
isArchived: false,
isPinned: false,
isPublic: false,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
blueprintId: data.blueprintId ?? null,
language: data.language ?? null,
// createdAt + updatedAt are required by LocalMemo's type but the
@ -168,6 +172,36 @@ export const memosStore = {
archive: (id: string) => memoArchive.archive(id),
unarchive: (id: string) => memoArchive.unarchive(id),
/**
* Flip a memo's visibility. M6 soft-migration: writes both
* `visibility` and the legacy `isPublic` mirror so older readers
* (search index, server snapshots) keep working until the M6.1
* hard-drop. Public memos surface in the user's website embed
* once a memoro embed-resolver lands.
*/
async setVisibility(id: string, next: VisibilityLevel) {
const existing = await memoTable.get(id);
if (!existing) throw new Error(`Memo ${id} not found`);
const before: VisibilityLevel = existing.visibility ?? (existing.isPublic ? 'public' : 'space');
if (before === next) return;
const stamp = new Date().toISOString();
await memoTable.update(id, {
visibility: next,
isPublic: next === 'public',
visibilityChangedAt: stamp,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: stamp,
});
emitDomainEvent('VisibilityChanged', 'memoro', 'memos', id, {
recordId: id,
collection: 'memos',
before,
after: next,
});
},
/** Pin a memo. */
async pin(id: string) {
await memoTable.update(id, {

View file

@ -3,6 +3,7 @@
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
export type ProcessingStatus = 'pending' | 'processing' | 'completed' | 'failed';
@ -15,7 +16,17 @@ export interface LocalMemo extends BaseRecord {
processingStatus: ProcessingStatus;
isArchived: boolean;
isPinned: boolean;
isPublic: boolean;
/**
* @deprecated Soft-migrating to unified `visibility`. Kept for the
* soak window so the converter can fall back to `isPublic` for
* legacy rows that haven't been touched since the M6 rollout.
* Hard-drop once `visibility` has propagated to all rows.
*/
isPublic?: boolean;
/** Unified visibility (M6 pilot — replaces isPublic). */
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
blueprintId: string | null;
language: string | null;
location?: Record<string, unknown>;
@ -84,7 +95,9 @@ export interface Memo {
processingStatus: ProcessingStatus;
isArchived: boolean;
isPinned: boolean;
/** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */
isPublic: boolean;
visibility: VisibilityLevel;
language: string | null;
createdAt: string;
updatedAt: string;

View file

@ -12,6 +12,7 @@
import type { QueuedTask } from '@mana/shared-llm';
import type { LlmTier } from '@mana/shared-llm';
import { PushPin } from '@mana/shared-icons';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import type { ViewProps } from '$lib/app-registry';
import type { LocalMemo, ProcessingStatus } from '../types';
@ -189,6 +190,15 @@
placeholder="z.B. de"
/>
</div>
<div class="prop-row">
<span class="prop-label">Sichtbarkeit</span>
<VisibilityPicker
level={memo.visibility ?? (memo.isPublic ? 'public' : 'space')}
onChange={(next: VisibilityLevel) => memosStore.setVisibility(memoId, next)}
disabledLevels={['unlisted']}
/>
</div>
</div>
<div class="section">

View file

@ -19,7 +19,8 @@ export function toDeck(local: LocalDeck): Deck {
title: local.title,
description: local.description ?? undefined,
themeId: local.themeId ?? undefined,
isPublic: local.isPublic,
isPublic: local.isPublic ?? local.visibility === 'public',
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'),
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};

View file

@ -10,6 +10,10 @@ import { presiDeckTable, slideTable } from '../collections';
import { toDeck, toSlide } from '../queries';
import { PresiEvents } from '@mana/shared-utils/analytics';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { getActiveSpace } from '$lib/data/scope';
import { getEffectiveUserId } from '$lib/data/current-user';
import { defaultVisibilityFor, type VisibilityLevel } from '@mana/shared-privacy';
import { createBlock, updateBlock } from '$lib/data/time-blocks/service';
import type {
LocalDeck,
@ -36,6 +40,7 @@ function createDecksStore() {
description: dto.description || null,
themeId: dto.themeId || null,
isPublic: false,
visibility: defaultVisibilityFor(getActiveSpace()?.type),
};
const plaintextSnapshot = toDeck(newLocal);
await encryptRecord('presiDecks', newLocal);
@ -60,7 +65,11 @@ function createDecksStore() {
if (dto.title !== undefined) localUpdates.title = dto.title;
if (dto.description !== undefined) localUpdates.description = dto.description;
if (dto.themeId !== undefined) localUpdates.themeId = dto.themeId;
if (dto.isPublic !== undefined) localUpdates.isPublic = dto.isPublic;
if (dto.isPublic !== undefined) {
// Mirror to unified visibility during M6 soak.
localUpdates.isPublic = dto.isPublic;
localUpdates.visibility = dto.isPublic ? 'public' : 'space';
}
await encryptRecord('presiDecks', localUpdates);
await presiDeckTable.update(id, localUpdates);
@ -72,6 +81,34 @@ function createDecksStore() {
}
}
async function setVisibility(id: string, next: VisibilityLevel): Promise<boolean> {
try {
const existing = await presiDeckTable.get(id);
if (!existing) return false;
const before: VisibilityLevel =
existing.visibility ?? (existing.isPublic ? 'public' : 'space');
if (before === next) return true;
const stamp = new Date().toISOString();
await presiDeckTable.update(id, {
visibility: next,
isPublic: next === 'public',
visibilityChangedAt: stamp,
visibilityChangedBy: getEffectiveUserId(),
updatedAt: stamp,
});
emitDomainEvent('VisibilityChanged', 'presi', 'presiDecks', id, {
recordId: id,
collection: 'presiDecks',
before,
after: next,
});
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to set visibility';
return false;
}
}
async function deleteDeck(id: string): Promise<boolean> {
error = null;
try {
@ -219,6 +256,7 @@ function createDecksStore() {
},
createDeck,
updateDeck,
setVisibility,
deleteDeck,
createSlide,
updateSlide,

View file

@ -3,12 +3,17 @@
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
export interface LocalDeck extends BaseRecord {
title: string;
description?: string | null;
themeId?: string | null;
/** @deprecated Use `visibility`. Mirror kept until M6.1 hard-drop. */
isPublic: boolean;
visibility?: VisibilityLevel;
visibilityChangedAt?: string;
visibilityChangedBy?: string;
activeRehearsalBlockId?: string | null;
}
@ -34,7 +39,9 @@ export interface Deck {
title: string;
description?: string;
themeId?: string;
/** @deprecated Use `visibility`. */
isPublic: boolean;
visibility: VisibilityLevel;
createdAt: string;
updatedAt: string;
}

View file

@ -9,6 +9,7 @@
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { decksStore } from '../stores/decks.svelte';
import { VisibilityPicker, type VisibilityLevel } from '@mana/shared-privacy';
import type { ViewProps } from '$lib/app-registry';
import type { LocalDeck, LocalSlide } from '../types';
@ -17,7 +18,6 @@
let editTitle = $state('');
let editDescription = $state('');
let editIsPublic = $state(false);
const detail = useDetailEntity<LocalDeck>({
id: () => deckId,
@ -25,7 +25,6 @@
onLoad: (val) => {
editTitle = val.title;
editDescription = val.description ?? '';
editIsPublic = val.isPublic;
},
});
@ -49,14 +48,8 @@
await decksStore.updateDeck(deckId, {
title: editTitle.trim() || detail.entity?.title || 'Unbenannt',
description: editDescription.trim() || undefined,
isPublic: editIsPublic,
});
}
async function handlePublicToggle() {
editIsPublic = !editIsPublic;
await decksStore.updateDeck(deckId, { isPublic: editIsPublic });
}
</script>
<DetailViewShell
@ -89,10 +82,12 @@
<div class="properties">
<div class="prop-row">
<span class="prop-label">Öffentlich</span>
<button class="toggle-btn" class:active={editIsPublic} onclick={handlePublicToggle}>
{editIsPublic ? 'Ja' : 'Nein'}
</button>
<span class="prop-label">Sichtbarkeit</span>
<VisibilityPicker
level={deck.visibility ?? (deck.isPublic ? 'public' : 'space')}
onChange={(next: VisibilityLevel) => decksStore.setVisibility(deckId, next)}
disabledLevels={['unlisted']}
/>
</div>
<div class="prop-row">

View file

@ -88,6 +88,7 @@ export const ULOAD_GUEST_SEED = {
color: '#8b5cf6',
icon: null,
isPublic: false,
visibility: 'space',
usageCount: 0,
},
{
@ -97,6 +98,7 @@ export const ULOAD_GUEST_SEED = {
color: '#3b82f6',
icon: null,
isPublic: false,
visibility: 'space',
usageCount: 0,
},
{
@ -106,6 +108,7 @@ export const ULOAD_GUEST_SEED = {
color: '#10b981',
icon: null,
isPublic: false,
visibility: 'space',
usageCount: 0,
},
] satisfies LocalTag[],

View file

@ -42,7 +42,9 @@ export interface Tag {
slug: string;
color?: string;
icon?: string;
/** @deprecated Use `visibility`. */
isPublic: boolean;
visibility: import('@mana/shared-privacy').VisibilityLevel;
usageCount: number;
createdAt: string;
updatedAt: string;
@ -104,7 +106,8 @@ export function toTag(local: LocalTag): Tag {
slug: local.slug,
color: local.color ?? undefined,
icon: local.icon ?? undefined,
isPublic: local.isPublic,
isPublic: local.isPublic ?? local.visibility === 'public',
visibility: local.visibility ?? (local.isPublic === true ? 'public' : 'space'),
usageCount: local.usageCount,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),

View file

@ -3,6 +3,7 @@
*/
import type { BaseRecord } from '@mana/local-store';
import type { VisibilityLevel } from '@mana/shared-privacy';
export interface LocalLink extends BaseRecord {
shortCode: string;
@ -29,7 +30,13 @@ export interface LocalTag extends BaseRecord {
slug: string;
color?: string | null;
icon?: string | null;
/**
* @deprecated Use `visibility`. Mirror kept for the M6 soak window.
* No active CRUD UI yet the field is set only by seed data and
* the future tag-management view will write `visibility` directly.
*/
isPublic: boolean;
visibility?: VisibilityLevel;
usageCount: number;
}