feat(tag-presets): Phase 2d.5a — applyPresetToSpace + copyTagsBetweenSpaces

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 18:02:06 +02:00
parent 9f4ebd8dad
commit 596e5a7424

View file

@ -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<LocalUserTagPreset>('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<number> {
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<string, string>();
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<number> {
const [rawTags, rawGroups] = await Promise.all([
db.table<LocalTagShape>('globalTags').toArray(),
db.table<LocalTagGroupShape>('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<LocalTagShape>('globalTags', sourceTags);
const decryptedGroups = await decryptRecords<LocalTagGroupShape>('tagGroups', sourceGroups);
const userId = getEffectiveUserId();
const timestamp = now();
const groupIdMap = new Map<string, string>();
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;
},
};