From a4f8c651a4b5b7be5af07b633d9abb3a90d926ad Mon Sep 17 00:00:00 2001 From: lew Date: Mon, 23 Mar 2026 20:33:18 +0000 Subject: [PATCH] feat(migrate): template migrations --- src/lib/components/UpgradeModal.svelte | 58 +++++++++++++ src/lib/data/parse.ts | 3 +- src/lib/state.svelte.ts | 111 ++++++++++++++++++++++++- src/lib/types.ts | 1 + src/lib/utils/template-diff.ts | 51 ++++++++++++ src/routes/+page.svelte | 3 + 6 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 src/lib/components/UpgradeModal.svelte create mode 100644 src/lib/utils/template-diff.ts diff --git a/src/lib/components/UpgradeModal.svelte b/src/lib/components/UpgradeModal.svelte new file mode 100644 index 0000000..8a9fdc7 --- /dev/null +++ b/src/lib/components/UpgradeModal.svelte @@ -0,0 +1,58 @@ + + +{#if upgrades.length > 0} + +

Templates Updated

+
+ {#each upgrades as upgrade} +
+

{upgrade.characterName}

+

{upgrade.templateName} template

+
    + {#each upgrade.diff.renamedFields as r} +
  • {r.from} → {r.to}
  • + {/each} + {#each upgrade.diff.addedRecords as r} +
  • + New record: {r}
  • + {/each} + {#each upgrade.diff.removedRecords as r} +
  • - Removed record: {r}
  • + {/each} + {#each upgrade.diff.addedFields as f} +
  • + New field: {f}
  • + {/each} + {#each upgrade.diff.removedFields as f} +
  • - Removed field: {f}
  • + {/each} +
+
+ + +
+
+ {/each} +
+

Your existing data will be preserved. Skipped updates will be offered again next time.

+
+{/if} diff --git a/src/lib/data/parse.ts b/src/lib/data/parse.ts index b8011ba..b1a36c4 100644 --- a/src/lib/data/parse.ts +++ b/src/lib/data/parse.ts @@ -63,7 +63,8 @@ function parseOptions(field: any): SelectOption[] { function parseField(raw: any): FieldDef { const base = { label: raw['@_label'], - ...(raw['@_required'] === 'true' && { required: true }) + ...(raw['@_required'] === 'true' && { required: true }), + ...(raw['@_from'] && { from: raw['@_from'] }) }; const type = raw['@_type']; diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts index c841477..5cd6485 100644 --- a/src/lib/state.svelte.ts +++ b/src/lib/state.svelte.ts @@ -1,29 +1,135 @@ import { getAllCharacters, saveCharacter, deleteCharacter } from './storage'; import { isBlankCharacter } from './utils/blank'; +import { presets } from './presets'; +import { diffTemplates, hasChanges, type TemplateDiff } from './utils/template-diff'; +import { slugify } from './utils/slugify'; import type { Character, Template } from './types'; +export interface PendingUpgrade { + characterId: string; + characterName: string; + templateName: string; + preset: Template; + diff: TemplateDiff; +} + let characters = $state([]); let activeId = $state(null); let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle'); +let pendingUpgrades = $state([]); let saveTimer: ReturnType | null = null; let statusTimer: ReturnType | null = null; +function getSkippedUpgrades(): Record { + try { + return JSON.parse(localStorage.getItem('skippedUpgrades') || '{}'); + } catch { + return {}; + } +} + +function upgradeFingerprint(preset: Template): string { + const fields = preset.records.flatMap((r) => r.fields.map((f) => f.label)).sort(); + return `${preset.id}:${fields.join(',')}`; +} + +function migrateData(char: Character, preset: Template) { + for (const record of preset.records) { + for (const field of record.fields) { + if (!field.from) continue; + const newKey = slugify(field.label); + if (char.data[newKey] !== undefined) continue; + const oldNames = field.from.split(',').map((s) => s.trim()); + for (const oldName of oldNames) { + const oldKey = slugify(oldName); + if (char.data[oldKey] !== undefined) { + char.data[newKey] = char.data[oldKey]; + delete char.data[oldKey]; + break; + } + } + } + } +} + +function charDisplayName(char: Character): string { + return (char.data['name'] as string) + || (char.data['designation'] as string) + || 'Unnamed Character'; +} + export const roster = { get characters() { return characters; }, get active() { return characters.find((c) => c.id === activeId) ?? null; }, get saveStatus() { return saveStatus; }, + get pendingUpgrades() { return pendingUpgrades; }, + + clearUpgrades() { + pendingUpgrades = []; + }, + + async applyUpgrade(characterId: string) { + const upgrade = pendingUpgrades.find((u) => u.characterId === characterId); + const char = characters.find((c) => c.id === characterId); + if (!upgrade || !char) return; + + migrateData(char, upgrade.preset); + char.template = upgrade.preset; + await saveCharacter($state.snapshot(char)); + const skipped = getSkippedUpgrades(); + delete skipped[characterId]; + localStorage.setItem('skippedUpgrades', JSON.stringify(skipped)); + pendingUpgrades = pendingUpgrades.filter((u) => u.characterId !== characterId); + }, + + skipUpgrade(characterId: string) { + const upgrade = pendingUpgrades.find((u) => u.characterId === characterId); + if (upgrade) { + const skipped = getSkippedUpgrades(); + skipped[characterId] = upgradeFingerprint(upgrade.preset); + localStorage.setItem('skippedUpgrades', JSON.stringify(skipped)); + } + pendingUpgrades = pendingUpgrades.filter((u) => u.characterId !== characterId); + }, async load() { const all = await getAllCharacters(); const kept: Character[] = []; + const upgrades: PendingUpgrade[] = []; + const skipped = getSkippedUpgrades(); + for (const char of all) { if (isBlankCharacter(char)) { await deleteCharacter(char.id); - } else { - kept.push(char); + continue; } + + if (char.template.id.startsWith('preset:')) { + const preset = presets.find((p) => p.id === char.template.id); + if (preset) { + const diff = diffTemplates(char.template, preset); + if (hasChanges(diff)) { + const fp = upgradeFingerprint(preset); + if (skipped[char.id] === fp) { + kept.push(char); + continue; + } + upgrades.push({ + characterId: char.id, + characterName: charDisplayName(char), + templateName: preset.name, + preset, + diff + }); + } + } + } + + kept.push(char); } + characters = kept; + pendingUpgrades = upgrades; const stored = localStorage.getItem('activeCharacterId'); if (stored && characters.some((c) => c.id === stored)) { @@ -49,6 +155,7 @@ export const roster = { async remove(id: string) { characters = characters.filter((c) => c.id !== id); await deleteCharacter(id); + pendingUpgrades = pendingUpgrades.filter((u) => u.characterId !== id); if (activeId === id) { activeId = characters[0]?.id ?? null; if (activeId) localStorage.setItem('activeCharacterId', activeId); diff --git a/src/lib/types.ts b/src/lib/types.ts index 7af977d..d7920b2 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -6,6 +6,7 @@ export interface SelectOption { export interface BaseFieldDef { label: string; required?: boolean; + from?: string; } export interface TextField extends BaseFieldDef { diff --git a/src/lib/utils/template-diff.ts b/src/lib/utils/template-diff.ts new file mode 100644 index 0000000..f825b44 --- /dev/null +++ b/src/lib/utils/template-diff.ts @@ -0,0 +1,51 @@ +import type { Template } from '../types'; + +export interface TemplateDiff { + addedFields: string[]; + removedFields: string[]; + renamedFields: { from: string; to: string }[]; + addedRecords: string[]; + removedRecords: string[]; +} + +export function diffTemplates(old: Template, current: Template): TemplateDiff { + const oldRecordTypes = new Set(old.records.map((r) => r.type)); + const newRecordTypes = new Set(current.records.map((r) => r.type)); + + const oldFields = new Set(old.records.flatMap((r) => r.fields.map((f) => f.label))); + const newFields = new Set(current.records.flatMap((r) => r.fields.map((f) => f.label))); + + // Detect renames via `from` attribute + const renamedFields: { from: string; to: string }[] = []; + const renamedOld = new Set(); + const renamedNew = new Set(); + + for (const record of current.records) { + for (const field of record.fields) { + if (!field.from) continue; + const fromNames = field.from.split(',').map((s) => s.trim()); + const match = fromNames.find((f) => oldFields.has(f)); + if (match && !newFields.has(match)) { + renamedFields.push({ from: match, to: field.label }); + renamedOld.add(match); + renamedNew.add(field.label); + } + } + } + + return { + addedFields: [...newFields].filter((f) => !oldFields.has(f) && !renamedNew.has(f)), + removedFields: [...oldFields].filter((f) => !newFields.has(f) && !renamedOld.has(f)), + renamedFields, + addedRecords: [...newRecordTypes].filter((r) => !oldRecordTypes.has(r)), + removedRecords: [...oldRecordTypes].filter((r) => !newRecordTypes.has(r)) + }; +} + +export function hasChanges(diff: TemplateDiff): boolean { + return diff.addedFields.length > 0 + || diff.removedFields.length > 0 + || diff.renamedFields.length > 0 + || diff.addedRecords.length > 0 + || diff.removedRecords.length > 0; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 8ae07f9..2388afe 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -7,6 +7,7 @@ import { roster } from '$lib/state.svelte'; import { presets } from '$lib/presets'; import TemplatePicker from '$lib/components/TemplatePicker.svelte'; + import UpgradeModal from '$lib/components/UpgradeModal.svelte'; let importData = $state(null); let mobileView = $state<'edit' | 'preview' | 'split'>('split'); @@ -103,3 +104,5 @@ {/if} {/if} + +