feat(migrate): template migrations

This commit is contained in:
Lewis Wynne 2026-03-23 20:33:18 +00:00
parent abe0755abc
commit a4f8c651a4
6 changed files with 224 additions and 3 deletions

View file

@ -0,0 +1,58 @@
<script lang="ts">
import { roster } from '$lib/state.svelte';
import Modal from './Modal.svelte';
let upgrades = $derived(roster.pendingUpgrades);
function dismiss() {
roster.clearUpgrades();
}
</script>
{#if upgrades.length > 0}
<Modal onClose={dismiss}>
<h2 class="font-semibold mb-3">Templates Updated</h2>
<div class="flex flex-col gap-4">
{#each upgrades as upgrade}
<div class="rounded border px-3 py-2" style="border-color: var(--border);">
<p class="text-sm font-medium">{upgrade.characterName}</p>
<p class="text-xs mb-2" style="color: var(--text-muted);">{upgrade.templateName} template</p>
<ul class="text-xs flex flex-col gap-0.5 mb-2" style="color: var(--text-muted);">
{#each upgrade.diff.renamedFields as r}
<li>{r.from}{r.to}</li>
{/each}
{#each upgrade.diff.addedRecords as r}
<li>+ New record: {r}</li>
{/each}
{#each upgrade.diff.removedRecords as r}
<li>- Removed record: {r}</li>
{/each}
{#each upgrade.diff.addedFields as f}
<li>+ New field: {f}</li>
{/each}
{#each upgrade.diff.removedFields as f}
<li>- Removed field: {f}</li>
{/each}
</ul>
<div class="flex gap-2">
<button
onclick={() => roster.applyUpgrade(upgrade.characterId)}
class="px-2 py-1 rounded text-xs border hover:opacity-80"
style="border-color: var(--accent); color: var(--accent);"
>
Update
</button>
<button
onclick={() => roster.skipUpgrade(upgrade.characterId)}
class="px-2 py-1 rounded text-xs hover:opacity-80"
style="color: var(--text-muted);"
>
Skip
</button>
</div>
</div>
{/each}
</div>
<p class="text-xs mt-3" style="color: var(--text-muted);">Your existing data will be preserved. Skipped updates will be offered again next time.</p>
</Modal>
{/if}

View file

@ -63,7 +63,8 @@ function parseOptions(field: any): SelectOption[] {
function parseField(raw: any): FieldDef { function parseField(raw: any): FieldDef {
const base = { const base = {
label: raw['@_label'], label: raw['@_label'],
...(raw['@_required'] === 'true' && { required: true }) ...(raw['@_required'] === 'true' && { required: true }),
...(raw['@_from'] && { from: raw['@_from'] })
}; };
const type = raw['@_type']; const type = raw['@_type'];

View file

@ -1,29 +1,135 @@
import { getAllCharacters, saveCharacter, deleteCharacter } from './storage'; import { getAllCharacters, saveCharacter, deleteCharacter } from './storage';
import { isBlankCharacter } from './utils/blank'; 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'; import type { Character, Template } from './types';
export interface PendingUpgrade {
characterId: string;
characterName: string;
templateName: string;
preset: Template;
diff: TemplateDiff;
}
let characters = $state<Character[]>([]); let characters = $state<Character[]>([]);
let activeId = $state<string | null>(null); let activeId = $state<string | null>(null);
let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle'); let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle');
let pendingUpgrades = $state<PendingUpgrade[]>([]);
let saveTimer: ReturnType<typeof setTimeout> | null = null; let saveTimer: ReturnType<typeof setTimeout> | null = null;
let statusTimer: ReturnType<typeof setTimeout> | null = null; let statusTimer: ReturnType<typeof setTimeout> | null = null;
function getSkippedUpgrades(): Record<string, string> {
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 = { export const roster = {
get characters() { return characters; }, get characters() { return characters; },
get active() { return characters.find((c) => c.id === activeId) ?? null; }, get active() { return characters.find((c) => c.id === activeId) ?? null; },
get saveStatus() { return saveStatus; }, 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() { async load() {
const all = await getAllCharacters(); const all = await getAllCharacters();
const kept: Character[] = []; const kept: Character[] = [];
const upgrades: PendingUpgrade[] = [];
const skipped = getSkippedUpgrades();
for (const char of all) { for (const char of all) {
if (isBlankCharacter(char)) { if (isBlankCharacter(char)) {
await deleteCharacter(char.id); await deleteCharacter(char.id);
} else { 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); kept.push(char);
} }
}
characters = kept; characters = kept;
pendingUpgrades = upgrades;
const stored = localStorage.getItem('activeCharacterId'); const stored = localStorage.getItem('activeCharacterId');
if (stored && characters.some((c) => c.id === stored)) { if (stored && characters.some((c) => c.id === stored)) {
@ -49,6 +155,7 @@ export const roster = {
async remove(id: string) { async remove(id: string) {
characters = characters.filter((c) => c.id !== id); characters = characters.filter((c) => c.id !== id);
await deleteCharacter(id); await deleteCharacter(id);
pendingUpgrades = pendingUpgrades.filter((u) => u.characterId !== id);
if (activeId === id) { if (activeId === id) {
activeId = characters[0]?.id ?? null; activeId = characters[0]?.id ?? null;
if (activeId) localStorage.setItem('activeCharacterId', activeId); if (activeId) localStorage.setItem('activeCharacterId', activeId);

View file

@ -6,6 +6,7 @@ export interface SelectOption {
export interface BaseFieldDef { export interface BaseFieldDef {
label: string; label: string;
required?: boolean; required?: boolean;
from?: string;
} }
export interface TextField extends BaseFieldDef { export interface TextField extends BaseFieldDef {

View file

@ -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<string>();
const renamedNew = new Set<string>();
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;
}

View file

@ -7,6 +7,7 @@
import { roster } from '$lib/state.svelte'; import { roster } from '$lib/state.svelte';
import { presets } from '$lib/presets'; import { presets } from '$lib/presets';
import TemplatePicker from '$lib/components/TemplatePicker.svelte'; import TemplatePicker from '$lib/components/TemplatePicker.svelte';
import UpgradeModal from '$lib/components/UpgradeModal.svelte';
let importData = $state<string | null>(null); let importData = $state<string | null>(null);
let mobileView = $state<'edit' | 'preview' | 'split'>('split'); let mobileView = $state<'edit' | 'preview' | 'split'>('split');
@ -103,3 +104,5 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<UpgradeModal />