From 596e5a7424ac980e6fe66df2db3f6e8016e031a4 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 22 Apr 2026 18:02:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(tag-presets):=20Phase=202d.5a=20=E2=80=94?= =?UTF-8?q?=20applyPresetToSpace=20+=20copyTagsBetweenSpaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two seeding helpers the Space-creation flow needs: - applyPresetToSpace(presetId, targetSpaceId): one-shot-copies a preset's frozen snapshot as fresh globalTags rows in the target Space. Creates tagGroups for each distinct groupName so the user's familiar grouping carries over. Not a live link — renaming the preset afterwards doesn't rename applied tags. - copyTagsBetweenSpaces(sourceSpaceId, targetSpaceId): duplicates every non-deleted tag + tagGroup from one Space into another with fresh ids. Powers the "copy tags from my current Space" option in SpaceCreateDialog so solo-Space users don't have to build a named preset before they inherit their existing taxonomy. Both helpers explicitly stamp spaceId on every written row so the write lands in the TARGET Space even while the caller's active-space context is still the SOURCE Space (SpaceCreateDialog: create Space → apply preset → activate → reload). The Dexie creating-hook normally stamps spaceId from getActiveSpaceId(); pre-populating it makes the hook's `if undefined/null` guard skip. Both run inside a single Dexie transaction so a mid-batch failure doesn't leave a half-seeded Space. Duck-typed LocalTagShape / LocalTagGroupShape local to this file — the authoritative types live in @mana/shared-stores but importing them here would create an awkward data-layer → shared-stores dependency direction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/lib/data/tag-presets/store.svelte.ts | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts b/apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts index ead5046f9..20e117fe9 100644 --- a/apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts +++ b/apps/mana/apps/web/src/lib/data/tag-presets/store.svelte.ts @@ -25,6 +25,35 @@ import type { } from './types'; import { toUserTagPreset } from './types'; +// Minimal duck-typed local shapes for the tag tables. Full types live +// in @mana/shared-stores but the fields we write are a strict subset — +// importing the shared-stores type from a data-layer module would create +// an awkward dependency direction. +interface LocalTagShape { + id: string; + spaceId: string; + userId?: string; + name: string; + color: string; + icon?: string | null; + groupId?: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +interface LocalTagGroupShape { + id: string; + spaceId: string; + userId?: string; + name: string; + color: string; + icon?: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + const table = db.table('userTagPresets'); function now(): string { @@ -108,4 +137,152 @@ export const tagPresetsStore = { const next = [...(decrypted?.tags ?? []), entry]; await this.updatePreset(id, { tags: next }); }, + + /** + * One-shot-copy a preset's entries into a target Space as fresh + * globalTags rows (+ tagGroups for each distinct groupName). No + * live link — renaming the preset afterwards does not rename the + * applied tags. Returns the number of tags created. + * + * Stamps `spaceId` explicitly on every written row so the write + * lands in the target Space even if the user's active-space + * context is still the source Space when this runs (SpaceCreateDialog + * flow: create Space → apply preset → activate Space → reload). + */ + async applyPresetToSpace(presetId: string, targetSpaceId: string): Promise { + const existing = await table.get(presetId); + if (!existing) throw new Error(`Preset ${presetId} not found`); + const [decrypted] = await decryptRecords('userTagPresets', [existing]); + if (!decrypted) return 0; + + const userId = getEffectiveUserId(); + const timestamp = now(); + + // Build a groupName → new groupId map so multiple tag entries + // sharing the same groupName land in the same freshly-created + // tagGroups row. + const groupMap = new Map(); + const groupsToWrite: LocalTagGroupShape[] = []; + for (const entry of decrypted.tags ?? []) { + if (!entry.groupName || groupMap.has(entry.groupName)) continue; + const groupId = crypto.randomUUID(); + groupMap.set(entry.groupName, groupId); + groupsToWrite.push({ + id: groupId, + spaceId: targetSpaceId, + userId, + name: entry.groupName, + color: '#6b7280', + icon: null, + sortOrder: groupMap.size - 1, + createdAt: timestamp, + updatedAt: timestamp, + }); + } + + const tagsToWrite: LocalTagShape[] = []; + let sortOrder = 0; + for (const entry of decrypted.tags ?? []) { + tagsToWrite.push({ + id: crypto.randomUUID(), + spaceId: targetSpaceId, + userId, + name: entry.name, + color: entry.color, + icon: entry.icon ?? null, + groupId: entry.groupName ? (groupMap.get(entry.groupName) ?? null) : null, + sortOrder: sortOrder++, + createdAt: timestamp, + updatedAt: timestamp, + }); + } + + // Encrypt + write each row. The Dexie creating-hook stamps + // __lastActor / __fieldActors automatically; spaceId is + // pre-populated here so the hook leaves it alone. + await db.transaction('rw', db.table('globalTags'), db.table('tagGroups'), async () => { + for (const group of groupsToWrite) { + await encryptRecord('tagGroups', group); + await db.table('tagGroups').add(group); + } + for (const tag of tagsToWrite) { + await encryptRecord('globalTags', tag); + await db.table('globalTags').add(tag); + } + }); + + return tagsToWrite.length; + }, + + /** + * Copy every tag + tagGroup from `sourceSpaceId` into + * `targetSpaceId` with fresh ids. Used by the "copy tags from my + * current Space" convenience in SpaceCreateDialog so solo-Space + * users don't have to build a named preset before they can inherit + * their personal taxonomy. + */ + async copyTagsBetweenSpaces(sourceSpaceId: string, targetSpaceId: string): Promise { + const [rawTags, rawGroups] = await Promise.all([ + db.table('globalTags').toArray(), + db.table('tagGroups').toArray(), + ]); + + const sourceTags = rawTags.filter( + (t) => t.spaceId === sourceSpaceId && !(t as unknown as { deletedAt?: string }).deletedAt + ); + const sourceGroups = rawGroups.filter( + (g) => g.spaceId === sourceSpaceId && !(g as unknown as { deletedAt?: string }).deletedAt + ); + + if (sourceTags.length === 0 && sourceGroups.length === 0) return 0; + + const decryptedTags = await decryptRecords('globalTags', sourceTags); + const decryptedGroups = await decryptRecords('tagGroups', sourceGroups); + + const userId = getEffectiveUserId(); + const timestamp = now(); + + const groupIdMap = new Map(); + const groupsToWrite: LocalTagGroupShape[] = decryptedGroups.map((g) => { + const newId = crypto.randomUUID(); + groupIdMap.set(g.id, newId); + return { + id: newId, + spaceId: targetSpaceId, + userId, + name: g.name, + color: g.color, + icon: g.icon ?? null, + sortOrder: g.sortOrder, + createdAt: timestamp, + updatedAt: timestamp, + }; + }); + + const tagsToWrite: LocalTagShape[] = decryptedTags.map((t) => ({ + id: crypto.randomUUID(), + spaceId: targetSpaceId, + userId, + name: t.name, + color: t.color, + icon: t.icon ?? null, + groupId: t.groupId ? (groupIdMap.get(t.groupId) ?? null) : null, + sortOrder: t.sortOrder, + createdAt: timestamp, + updatedAt: timestamp, + })); + + await db.transaction('rw', db.table('globalTags'), db.table('tagGroups'), async () => { + for (const group of groupsToWrite) { + await encryptRecord('tagGroups', group); + await db.table('tagGroups').add(group); + } + for (const tag of tagsToWrite) { + await encryptRecord('globalTags', tag); + await db.table('globalTags').add(tag); + } + }); + + return tagsToWrite.length; + }, };