diff --git a/data/schema/template.xsd b/data/schema/template.xsd
index 4826112..8aac7f2 100644
--- a/data/schema/template.xsd
+++ b/data/schema/template.xsd
@@ -43,6 +43,7 @@
+
@@ -57,6 +58,7 @@
+
diff --git a/data/templates/ipc.xml b/data/templates/ipc.xml
index 63587a7..a6428a8 100644
--- a/data/templates/ipc.xml
+++ b/data/templates/ipc.xml
@@ -4,18 +4,8 @@
Basic identification information visible on all records.
-
-
-
+
-
-
-
-
-
-
-
-
@@ -25,9 +15,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/data/templates/standard.xml b/data/templates/standard.xml
index f6244d4..077a101 100644
--- a/data/templates/standard.xml
+++ b/data/templates/standard.xml
@@ -4,7 +4,7 @@
Basic identification information visible on all records.
-
+
@@ -19,6 +19,7 @@
+
diff --git a/package-lock.json b/package-lock.json
index 7e77c12..14db5e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -828,9 +828,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -845,9 +842,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -862,9 +856,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -879,9 +870,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -896,9 +884,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -913,9 +898,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -930,9 +912,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -947,9 +926,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -964,9 +940,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -981,9 +954,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -998,9 +968,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1015,9 +982,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1032,9 +996,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1375,9 +1336,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1395,9 +1353,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1415,9 +1370,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1435,9 +1387,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -2285,9 +2234,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2309,9 +2255,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2333,9 +2276,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -2357,9 +2297,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MPL-2.0",
"optional": true,
"os": [
diff --git a/src/lib/components/CharacterSwitcher.svelte b/src/lib/components/CharacterSwitcher.svelte
index fefd1ed..2d0b9b1 100644
--- a/src/lib/components/CharacterSwitcher.svelte
+++ b/src/lib/components/CharacterSwitcher.svelte
@@ -2,8 +2,10 @@
import { roster } from '$lib/state.svelte';
import { slugify } from '$lib/utils/slugify';
- function displayName(char: { data: Record }): string {
- const name = char.data[slugify('Name')];
+ function displayName(char: { template: { records: { fields: { type: string; label: string }[] }[] }; data: Record }): string {
+ const nameField = char.template.records.flatMap((r) => r.fields).find((f) => f.type === 'name');
+ const key = nameField ? slugify(nameField.label) : slugify('Name');
+ const name = char.data[key];
return (name as string) || 'Unnamed Character';
}
diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte
index ad32172..a922ccb 100644
--- a/src/lib/components/Header.svelte
+++ b/src/lib/components/Header.svelte
@@ -46,7 +46,9 @@
function displayName(): string {
const char = roster.active;
if (!char) return '';
- const name = char.data[slugify('Name')];
+ const nameField = char.template.records.flatMap((r) => r.fields).find((f) => f.type === 'name');
+ const key = nameField ? slugify(nameField.label) : slugify('Name');
+ const name = char.data[key];
return (name as string) || 'Unnamed Character';
}
diff --git a/src/lib/components/ImportModal.svelte b/src/lib/components/ImportModal.svelte
index 9c7a6c0..2296da8 100644
--- a/src/lib/components/ImportModal.svelte
+++ b/src/lib/components/ImportModal.svelte
@@ -63,7 +63,9 @@
function charName(): string {
if (!charData) return 'Unknown';
- return (charData.data[slugify('Name')] as string) || 'Unnamed Character';
+ const nameField = charData.template.records.flatMap((r: any) => r.fields).find((f: any) => f.type === 'name');
+ const key = nameField ? slugify(nameField.label) : slugify('Name');
+ return (charData.data[key] as string) || 'Unnamed Character';
}
async function importCharacter() {
diff --git a/src/lib/components/RecordCard.svelte b/src/lib/components/RecordCard.svelte
index 793f468..cfb1a9f 100644
--- a/src/lib/components/RecordCard.svelte
+++ b/src/lib/components/RecordCard.svelte
@@ -13,8 +13,9 @@
let expanded = $state(false);
+ let dataFields = $derived(record.fields.filter((f) => f.type !== 'separator'));
let filled = $derived(
- record.fields.filter((f) => {
+ dataFields.filter((f) => {
const v = data[slugify(f.label)];
if (v === undefined || v === null || v === '' || v === 0) return false;
if (Array.isArray(v) && v.length === 0) return false;
@@ -47,7 +48,7 @@
{/if}
- {filled}/{record.fields.length}
+ {filled}/{dataFields.length}
diff --git a/src/lib/components/SchemaForm.svelte b/src/lib/components/SchemaForm.svelte
index a976b3b..9654bd9 100644
--- a/src/lib/components/SchemaForm.svelte
+++ b/src/lib/components/SchemaForm.svelte
@@ -14,6 +14,17 @@
let showTemplateSwitcher = $state(false);
let showMigrationModal = $state(false);
+ let speciesKeys = $derived(new Set(
+ character.template.records.flatMap((r) => r.fields)
+ .filter((f) => f.type === 'species')
+ .map((f) => slugify(f.label))
+ ));
+ const SPECIES_DEPENDENT_TYPES = new Set(['subspecies', 'citizenship', 'languages']);
+ let speciesDependentKeys = $derived(new Set(
+ character.template.records.flatMap((r) => r.fields)
+ .filter((f) => SPECIES_DEPENDENT_TYPES.has(f.type))
+ .map((f) => slugify(f.label))
+ ));
let speciesKey = slugify('Species');
let pendingMigration = $derived.by(() => {
@@ -59,9 +70,8 @@
return null;
});
- function switchTemplate(template: Template) {
- character.template = $state.snapshot(template);
- roster.scheduleSave(character);
+ async function switchTemplate(template: Template) {
+ await roster.migrateToPreset(character, template);
dismissed = null;
showTemplateSwitcher = false;
}
@@ -143,6 +153,11 @@
data={character.data}
onFieldChange={(key, value) => {
character.data[key] = value;
+ if (speciesKeys.has(key)) {
+ for (const depKey of speciesDependentKeys) {
+ character.data[depKey] = '';
+ }
+ }
roster.scheduleSave(character);
}}
/>
diff --git a/src/lib/components/fields/CitizenshipField.svelte b/src/lib/components/fields/CitizenshipField.svelte
index 6647a31..d15c9a2 100644
--- a/src/lib/components/fields/CitizenshipField.svelte
+++ b/src/lib/components/fields/CitizenshipField.svelte
@@ -1,8 +1,21 @@
+{:else}
+
+{/if}
diff --git a/src/lib/data/parse.ts b/src/lib/data/parse.ts
index fd07b20..0379da4 100644
--- a/src/lib/data/parse.ts
+++ b/src/lib/data/parse.ts
@@ -68,6 +68,7 @@ function parseField(raw: any): FieldDef {
const type = raw['@_type'];
switch (type) {
+ case 'name':
case 'text':
case 'textarea':
case 'date':
@@ -80,6 +81,8 @@ function parseField(raw: any): FieldDef {
case 'citizenship':
case 'languages':
return { ...base, type };
+ case 'separator':
+ return { type: 'separator', label: raw['@_label'] ?? '' };
case 'number':
return {
...base,
diff --git a/src/lib/file.ts b/src/lib/file.ts
index 3bd5e8e..5a60c17 100644
--- a/src/lib/file.ts
+++ b/src/lib/file.ts
@@ -1,6 +1,7 @@
import type { Character, Template } from './types';
import { pruneEmpty } from './sharing';
import { presets } from './presets';
+import { slugify } from './utils/slugify';
interface CharacterFilePayload {
version: number;
@@ -44,7 +45,9 @@ export function parseCharacterFile(json: string): { template: Template | Omit r.fields).find((f) => f.type === 'name');
+ const key = nameField ? slugify(nameField.label) : 'name';
+ const name = char.data[key] as string | undefined;
if (!name || !name.trim()) return 'character.json';
return name.trim().replace(/[^a-zA-Z0-9'-]/g, '-').replace(/-+/g, '-') + '.json';
}
diff --git a/src/lib/output.ts b/src/lib/output.ts
index f64ed37..da0647c 100644
--- a/src/lib/output.ts
+++ b/src/lib/output.ts
@@ -11,6 +11,7 @@ export function formatFieldOutput(
currentSpecies?: string
): string | null {
switch (field.type) {
+ case 'name':
case 'text':
case 'date':
case 'citizenship':
@@ -125,12 +126,32 @@ function renderFields(
speciesData?: SpeciesData[],
currentSpecies?: string
): string[] {
- const lines: string[] = [];
+ // Split fields into groups by separator boundaries
+ const groups: FieldDef[][] = [[]];
for (const field of fields) {
- const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies);
- if (out) lines.push(out);
+ if (field.type === 'separator') {
+ groups.push([]);
+ } else {
+ groups[groups.length - 1].push(field);
+ }
}
- return lines;
+
+ const rendered = groups.map((group) => {
+ const lines: string[] = [];
+ for (const field of group) {
+ const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies);
+ if (out) lines.push(out);
+ }
+ return lines;
+ });
+
+ const result: string[] = [];
+ for (const lines of rendered) {
+ if (!lines.length) continue;
+ if (result.length) result.push('');
+ result.push(...lines);
+ }
+ return result;
}
function splitLines(text: string | undefined): string[] {
diff --git a/src/lib/schema.ts b/src/lib/schema.ts
index 3ed15db..fce4df7 100644
--- a/src/lib/schema.ts
+++ b/src/lib/schema.ts
@@ -4,6 +4,7 @@ import { slugify } from './utils/slugify';
function zodForField(field: FieldDef): z.ZodTypeAny {
switch (field.type) {
+ case 'name':
case 'text':
case 'textarea':
case 'list':
@@ -30,6 +31,7 @@ export function buildCharacterSchema(template: Template): z.ZodObject = {};
for (const record of template.records) {
for (const field of record.fields) {
+ if (field.type === 'separator') continue;
shape[slugify(field.label)] = zodForField(field);
}
}
diff --git a/src/lib/state.svelte.ts b/src/lib/state.svelte.ts
index 7230481..85d48d3 100644
--- a/src/lib/state.svelte.ts
+++ b/src/lib/state.svelte.ts
@@ -9,6 +9,14 @@ let saveStatus = $state<'idle' | 'saving' | 'saved'>('idle');
let saveTimer: ReturnType | null = null;
let statusTimer: ReturnType | null = null;
+const SINGLETON_TYPES = new Set([
+ 'name', 'species', 'subspecies', 'citizenship', 'languages', 'height', 'weight'
+]);
+
+function allFields(template: Template) {
+ return template.records.flatMap((r) => r.fields).filter((f) => f.type !== 'separator');
+}
+
function migrateData(char: Character, preset: Template) {
for (const record of preset.records) {
for (const field of record.fields) {
@@ -26,6 +34,22 @@ function migrateData(char: Character, preset: Template) {
}
}
}
+
+ const oldByType = new Map();
+ for (const f of allFields(char.template)) {
+ if (SINGLETON_TYPES.has(f.type)) {
+ oldByType.set(f.type, slugify(f.label));
+ }
+ }
+ for (const f of allFields(preset)) {
+ if (!SINGLETON_TYPES.has(f.type)) continue;
+ const newKey = slugify(f.label);
+ if (char.data[newKey] !== undefined) continue;
+ const oldKey = oldByType.get(f.type);
+ if (oldKey && oldKey !== newKey && char.data[oldKey] !== undefined) {
+ char.data[newKey] = char.data[oldKey];
+ }
+ }
}
export const roster = {
diff --git a/src/lib/types.ts b/src/lib/types.ts
index d7920b2..4aaf7a3 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -9,6 +9,11 @@ export interface BaseFieldDef {
from?: string;
}
+export interface NameField extends BaseFieldDef {
+ type: 'name';
+ placeholder?: string;
+}
+
export interface TextField extends BaseFieldDef {
type: 'text';
placeholder?: string;
@@ -74,7 +79,13 @@ export interface LanguagesField extends BaseFieldDef {
type: 'languages';
}
+export interface SeparatorField {
+ type: 'separator';
+ label: string;
+}
+
export type FieldDef =
+ | NameField
| TextField
| TextareaField
| ListField
@@ -88,7 +99,8 @@ export type FieldDef =
| SpeciesField
| SubspeciesField
| CitizenshipField
- | LanguagesField;
+ | LanguagesField
+ | SeparatorField;
export interface RecordDef {
type: string;
diff --git a/src/lib/utils/blank.ts b/src/lib/utils/blank.ts
index 5cb6744..9501903 100644
--- a/src/lib/utils/blank.ts
+++ b/src/lib/utils/blank.ts
@@ -1,13 +1,11 @@
import type { Character } from '../types';
-const DEFAULT_LANGUAGES = ['Tau Ceti Basic'];
-
export function isBlankCharacter(char: Character): boolean {
+ if (!char.data) return true;
for (const value of Object.values(char.data)) {
if (value === '' || value === undefined || value === null || value === 0) continue;
if (Array.isArray(value)) {
if (value.length === 0) continue;
- if (value.length === 1 && DEFAULT_LANGUAGES.includes(value[0])) continue;
return false;
}
return false;