feat(migrate): template migrations
This commit is contained in:
parent
abe0755abc
commit
a4f8c651a4
6 changed files with 224 additions and 3 deletions
58
src/lib/components/UpgradeModal.svelte
Normal file
58
src/lib/components/UpgradeModal.svelte
Normal 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}
|
||||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Character[]>([]);
|
||||
let activeId = $state<string | null>(null);
|
||||
let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle');
|
||||
let pendingUpgrades = $state<PendingUpgrade[]>([]);
|
||||
let saveTimer: 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 = {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export interface SelectOption {
|
|||
export interface BaseFieldDef {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
from?: string;
|
||||
}
|
||||
|
||||
export interface TextField extends BaseFieldDef {
|
||||
|
|
|
|||
51
src/lib/utils/template-diff.ts
Normal file
51
src/lib/utils/template-diff.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue