Compare commits
2 commits
a0060ca4bb
...
12cb8c387f
| Author | SHA1 | Date | |
|---|---|---|---|
| 12cb8c387f | |||
| a2b904811a |
25 changed files with 331 additions and 76 deletions
|
|
@ -3,8 +3,19 @@
|
||||||
|
|
||||||
<xs:element name="species">
|
<xs:element name="species">
|
||||||
<xs:complexType>
|
<xs:complexType>
|
||||||
<xs:sequence>
|
<xs:all>
|
||||||
<xs:element name="description" type="xs:string" minOccurs="0" />
|
<xs:element name="description" type="xs:string" minOccurs="0" />
|
||||||
|
<xs:element name="labels" minOccurs="0">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="label" maxOccurs="unbounded">
|
||||||
|
<xs:complexType mixed="true">
|
||||||
|
<xs:attribute name="for" type="xs:string" use="required" />
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
<xs:element name="languages" type="refList" />
|
<xs:element name="languages" type="refList" />
|
||||||
<xs:element name="citizenships" type="refList" />
|
<xs:element name="citizenships" type="refList" />
|
||||||
<xs:element name="subspecies" minOccurs="0">
|
<xs:element name="subspecies" minOccurs="0">
|
||||||
|
|
@ -22,10 +33,9 @@
|
||||||
</xs:sequence>
|
</xs:sequence>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
</xs:element>
|
</xs:element>
|
||||||
</xs:sequence>
|
</xs:all>
|
||||||
<xs:attribute name="id" type="xs:string" use="required" />
|
<xs:attribute name="id" type="xs:string" use="required" />
|
||||||
<xs:attribute name="name" type="xs:string" use="required" />
|
<xs:attribute name="name" type="xs:string" use="required" />
|
||||||
<xs:attribute name="subspeciesLabel" type="xs:string" use="required" />
|
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
</xs:element>
|
</xs:element>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<species id="diona" name="Diona" subspeciesLabel="Subspecies">
|
<species id="diona" name="Diona">
|
||||||
<description>A mysterious plant-like species hailing from the depths of space. Dionae are comprised of cat-sized caterpillar-like creatures called nymphs, which form gestalt consciousnesses when two or more combine. Almost every aspect of the species is a mystery: their origins, behaviour, and functions. They are capable of great intellectual and biological feats, surviving primarily off the electromagnetic spectrum and biological matter.</description>
|
<description>A mysterious plant-like species hailing from the depths of space. Dionae are comprised of cat-sized caterpillar-like creatures called nymphs, which form gestalt consciousnesses when two or more combine. Almost every aspect of the species is a mystery: their origins, behaviour, and functions. They are capable of great intellectual and biological feats, surviving primarily off the electromagnetic spectrum and biological matter.</description>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<species id="human" name="Human" subspeciesLabel="Variant">
|
<species id="human" name="Human">
|
||||||
|
<labels>
|
||||||
|
<label for="subspecies">Variant</label>
|
||||||
|
</labels>
|
||||||
<description>Humans originated on Earth.</description>
|
<description>Humans originated on Earth.</description>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<species id="ipc" name="Integrated Positronic Chassis" subspeciesLabel="Frame">
|
<species id="ipc" name="Integrated Positronic Chassis">
|
||||||
|
<labels>
|
||||||
|
<label for="subspecies">Frame</label>
|
||||||
|
</labels>
|
||||||
<description>IPCs are synthetic chassis housing a positronic processing core. From skeletal baselines to humanlike shells, they serve in a variety of roles across the Orion Spur. Their legal status varies wildly between nations, ranging from full citizenship to being classified as property.</description>
|
<description>IPCs are synthetic chassis housing a positronic processing core. From skeletal baselines to humanlike shells, they serve in a variety of roles across the Orion Spur. Their legal status varies wildly between nations, ranging from full citizenship to being classified as property.</description>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<species id="skrell" name="Skrell" subspeciesLabel="Subspecies">
|
<species id="skrell" name="Skrell">
|
||||||
<description>Skrell are a species of amphibious bipeds, originating from the planet of Qerrbalak. With longer lifespans than most sophonts, combined with their earlier development of organized societies, skrell are the oldest spacefaring species in the Orion Spur.</description>
|
<description>Skrell are a species of amphibious bipeds, originating from the planet of Qerrbalak. With longer lifespans than most sophonts, combined with their earlier development of organized societies, skrell are the oldest spacefaring species in the Orion Spur.</description>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<species id="tajara" name="Tajara" subspeciesLabel="Ethnicity">
|
<species id="tajara" name="Tajara">
|
||||||
|
<labels>
|
||||||
|
<label for="subspecies">Ethnicity</label>
|
||||||
|
</labels>
|
||||||
<description>The Tajara are a race of humanoids that possess markedly felinoid traits. Tajaran history and society is deeply entrenched in the conflict between its caste system and ruling governments.</description>
|
<description>The Tajara are a race of humanoids that possess markedly felinoid traits. Tajaran history and society is deeply entrenched in the conflict between its caste system and ruling governments.</description>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<species id="unathi" name="Unathi" subspeciesLabel="Subspecies">
|
<species id="unathi" name="Unathi">
|
||||||
<description>A heavily reptilian species, Unathi hail from the Uuosa-Eso system. A relatively recent addition to the galactic stage, they suffered immense turmoil after the cultural and economic disruption following first contact with humanity. With their homeworld of Moghes suffering catastrophic climate change from a nuclear war in the recent past, the Izweski Hegemony that rules the majority of the species struggles to find its place in the galaxy. They hold ideals of honesty, virtue, martial combat, and spirituality above all else.</description>
|
<description>A heavily reptilian species, Unathi hail from the Uuosa-Eso system. A relatively recent addition to the galactic stage, they suffered immense turmoil after the cultural and economic disruption following first contact with humanity. With their homeworld of Moghes suffering catastrophic climate change from a nuclear war in the recent past, the Izweski Hegemony that rules the majority of the species struggles to find its place in the galaxy. They hold ideals of honesty, virtue, martial combat, and spirituality above all else.</description>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<species id="vaurca" name="Vaurca" subspeciesLabel="Type">
|
<species id="vaurca" name="Vaurca">
|
||||||
|
<labels>
|
||||||
|
<label for="subspecies">Type</label>
|
||||||
|
</labels>
|
||||||
<description>The Vaurca are an insectoid species with a complex caste-based society organised into Hives, each led by a Queen. They communicate primarily through a localised expression of their hivemind. Having arrived in the Orion Spur from deep space, they are one of the newest species to join the galactic stage, and their integration into existing societies remains an ongoing process.</description>
|
<description>The Vaurca are an insectoid species with a complex caste-based society organised into Hives, each led by a Queen. They communicate primarily through a localised expression of their hivemind. Having arrived in the Orion Spur from deep space, they are one of the newest species to join the galactic stage, and their integration into existing societies remains an ongoing process.</description>
|
||||||
|
|
||||||
<languages>
|
<languages>
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,11 @@
|
||||||
import DynamicField from './fields/DynamicField.svelte';
|
import DynamicField from './fields/DynamicField.svelte';
|
||||||
import { slugify } from '$lib/utils/slugify';
|
import { slugify } from '$lib/utils/slugify';
|
||||||
|
|
||||||
let { record, data, onFieldChange }: {
|
let { record, data, onFieldChange, onSave }: {
|
||||||
record: RecordDef;
|
record: RecordDef;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
onFieldChange: (key: string, value: any) => void;
|
onFieldChange: (key: string, value: any) => void;
|
||||||
|
onSave: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
|
|
@ -76,6 +77,7 @@
|
||||||
{field}
|
{field}
|
||||||
value={data[key]}
|
value={data[key]}
|
||||||
{data}
|
{data}
|
||||||
|
{onSave}
|
||||||
onChange={(v) => onFieldChange(key, v)}
|
onChange={(v) => onFieldChange(key, v)}
|
||||||
/>
|
/>
|
||||||
{#if hasError}
|
{#if hasError}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@
|
||||||
let dismissed = $state<string | null>(null);
|
let dismissed = $state<string | null>(null);
|
||||||
let showTemplateSwitcher = $state(false);
|
let showTemplateSwitcher = $state(false);
|
||||||
let showMigrationModal = $state(false);
|
let showMigrationModal = $state(false);
|
||||||
|
|
||||||
let speciesKeys = $derived(new Set(
|
let speciesKeys = $derived(new Set(
|
||||||
character.template.records.flatMap((r) => r.fields)
|
character.template.records.flatMap((r) => r.fields)
|
||||||
.filter((f) => f.type === 'species')
|
.filter((f) => f.type === 'species')
|
||||||
|
|
@ -160,6 +159,7 @@
|
||||||
}
|
}
|
||||||
roster.scheduleSave(character);
|
roster.scheduleSave(character);
|
||||||
}}
|
}}
|
||||||
|
onSave={() => roster.scheduleSave(character)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FieldDef } from '$lib/types';
|
import type { FieldDef } from '$lib/types';
|
||||||
|
import { species } from '$lib/data';
|
||||||
|
import { slugify } from '$lib/utils/slugify';
|
||||||
|
import { resolveFieldLabel } from '$lib/utils/resolve-label';
|
||||||
|
import { getFlag, setFlag } from '$lib/utils/field-flags';
|
||||||
import TextField from './TextField.svelte';
|
import TextField from './TextField.svelte';
|
||||||
import TextareaField from './TextareaField.svelte';
|
import TextareaField from './TextareaField.svelte';
|
||||||
import ListField from './ListField.svelte';
|
import ListField from './ListField.svelte';
|
||||||
|
|
@ -16,14 +20,38 @@
|
||||||
import LanguagesField from './LanguagesField.svelte';
|
import LanguagesField from './LanguagesField.svelte';
|
||||||
import SeparatorField from './SeparatorField.svelte';
|
import SeparatorField from './SeparatorField.svelte';
|
||||||
|
|
||||||
let { field, value, onChange, data }: {
|
let { field, value, onChange, data, onSave }: {
|
||||||
field: FieldDef;
|
field: FieldDef;
|
||||||
value: any;
|
value: any;
|
||||||
onChange: (v: any) => void;
|
onChange: (v: any) => void;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
|
onSave: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
let currentSpeciesId = $derived(data[slugify('Species')] as string | undefined);
|
||||||
|
let override = $derived(resolveFieldLabel(field, species, currentSpeciesId));
|
||||||
|
let key = $derived(slugify(field.label));
|
||||||
|
let useDynamic = $derived(getFlag(data, key, 'useSpeciesLabel'));
|
||||||
|
|
||||||
|
function toggleUseSpeciesLabel() {
|
||||||
|
setFlag(data, key, 'useSpeciesLabel', !useDynamic);
|
||||||
|
onSave();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<div class={override !== null ? 'relative' : ''}>
|
||||||
|
{#if override !== null}
|
||||||
|
<label class="absolute top-0 right-0 flex items-center gap-1 cursor-pointer z-10">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={useDynamic}
|
||||||
|
onchange={toggleUseSpeciesLabel}
|
||||||
|
class="accent-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
<span class="text-xs" style="color: var(--text-muted);">{override}</span>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if field.type === 'name'}
|
{#if field.type === 'name'}
|
||||||
<TextField field={{ ...field, type: 'text' }} {value} {onChange} />
|
<TextField field={{ ...field, type: 'text' }} {value} {onChange} />
|
||||||
{:else if field.type === 'text'}
|
{:else if field.type === 'text'}
|
||||||
|
|
@ -57,3 +85,4 @@
|
||||||
{:else if field.type === 'separator'}
|
{:else if field.type === 'separator'}
|
||||||
<SeparatorField {field} />
|
<SeparatorField {field} />
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@
|
||||||
|
|
||||||
let currentSpecies = $derived(species.find((s) => s.id === data[slugify('Species')]));
|
let currentSpecies = $derived(species.find((s) => s.id === data[slugify('Species')]));
|
||||||
let subs = $derived(currentSpecies?.subspecies ?? []);
|
let subs = $derived(currentSpecies?.subspecies ?? []);
|
||||||
let label = $derived(currentSpecies?.subspeciesLabel ?? field.label);
|
|
||||||
let selected = $derived(subs.find((s) => s.id === value));
|
let selected = $derived(subs.find((s) => s.id === value));
|
||||||
|
|
||||||
let custom = $state(false);
|
let custom = $state(false);
|
||||||
|
|
@ -33,7 +32,7 @@
|
||||||
{#if subs.length > 0 || isCustom}
|
{#if subs.length > 0 || isCustom}
|
||||||
<div class="block">
|
<div class="block">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<span class="text-sm font-medium">{label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
|
<span class="text-sm font-medium">{field.label}{#if field.required}<span style="color: var(--accent);"> *</span>{/if}</span>
|
||||||
{#if isCustom}
|
{#if isCustom}
|
||||||
<div class="flex items-center gap-2 mt-1">
|
<div class="flex items-center gap-2 mt-1">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
32
src/lib/data/parse.test.ts
Normal file
32
src/lib/data/parse.test.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parseSpecies } from './parse';
|
||||||
|
|
||||||
|
describe('parseSpecies', () => {
|
||||||
|
it('parses labels section into a record', () => {
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<species id="tajara" name="Tajara">
|
||||||
|
<labels>
|
||||||
|
<label for="subspecies">Ethnicity</label>
|
||||||
|
<label for="skin-color">Fur Colour</label>
|
||||||
|
</labels>
|
||||||
|
<subspecies>
|
||||||
|
<entry id="hharar" name="Hharar">
|
||||||
|
<description>The typical Tajara.</description>
|
||||||
|
</entry>
|
||||||
|
</subspecies>
|
||||||
|
</species>`;
|
||||||
|
const result = parseSpecies(xml);
|
||||||
|
expect(result.labels).toEqual({
|
||||||
|
subspecies: 'Ethnicity',
|
||||||
|
'skin-color': 'Fur Colour'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty labels when no labels section exists', () => {
|
||||||
|
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<species id="unathi" name="Unathi">
|
||||||
|
</species>`;
|
||||||
|
const result = parseSpecies(xml);
|
||||||
|
expect(result.labels).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ import type { Template, RecordDef, FieldDef, SelectOption } from '../types';
|
||||||
const parser = new XMLParser({
|
const parser = new XMLParser({
|
||||||
ignoreAttributes: false,
|
ignoreAttributes: false,
|
||||||
attributeNamePrefix: '@_',
|
attributeNamePrefix: '@_',
|
||||||
isArray: (name) => ['entry', 'ref', 'field', 'record', 'option', 'citizenship', 'language'].includes(name),
|
isArray: (name) => ['entry', 'ref', 'field', 'record', 'option', 'citizenship', 'language', 'label'].includes(name),
|
||||||
trimValues: true
|
trimValues: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -14,6 +14,15 @@ function extractRefs(container: any): string[] {
|
||||||
return container.ref.map((r: any) => r['@_id']);
|
return container.ref.map((r: any) => r['@_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractLabels(container: any): Record<string, string> {
|
||||||
|
if (!container?.label) return {};
|
||||||
|
const labels: Record<string, string> = {};
|
||||||
|
for (const l of container.label) {
|
||||||
|
labels[l['@_for']] = typeof l === 'string' ? l : l['#text'];
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSpecies(xml: string): SpeciesData {
|
export function parseSpecies(xml: string): SpeciesData {
|
||||||
const root = parser.parse(xml).species;
|
const root = parser.parse(xml).species;
|
||||||
const subspecies = root.subspecies?.entry ?? [];
|
const subspecies = root.subspecies?.entry ?? [];
|
||||||
|
|
@ -22,7 +31,7 @@ export function parseSpecies(xml: string): SpeciesData {
|
||||||
id: root['@_id'],
|
id: root['@_id'],
|
||||||
name: root['@_name'],
|
name: root['@_name'],
|
||||||
description: root.description?.trim(),
|
description: root.description?.trim(),
|
||||||
subspeciesLabel: root['@_subspeciesLabel'],
|
labels: extractLabels(root.labels),
|
||||||
languages: extractRefs(root.languages),
|
languages: extractRefs(root.languages),
|
||||||
citizenships: extractRefs(root.citizenships),
|
citizenships: extractRefs(root.citizenships),
|
||||||
subspecies: subspecies.map((e: any) => ({
|
subspecies: subspecies.map((e: any) => ({
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ export interface SpeciesData {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
subspeciesLabel: string;
|
labels: Record<string, string>;
|
||||||
subspecies: { id: string; name: string; description?: string }[];
|
subspecies: { id: string; name: string; description?: string }[];
|
||||||
languages: string[];
|
languages: string[];
|
||||||
citizenships: string[];
|
citizenships: string[];
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const stubSpecies: SpeciesData[] = [
|
||||||
id: 'human',
|
id: 'human',
|
||||||
name: 'Human',
|
name: 'Human',
|
||||||
description: '',
|
description: '',
|
||||||
subspeciesLabel: 'Variant',
|
labels: { subspecies: 'Variant' },
|
||||||
subspecies: [{ id: 'offworlder', name: 'Offworlder', description: '' }],
|
subspecies: [{ id: 'offworlder', name: 'Offworlder', description: '' }],
|
||||||
languages: ['tau-ceti-basic'],
|
languages: ['tau-ceti-basic'],
|
||||||
citizenships: ['biesel']
|
citizenships: ['biesel']
|
||||||
|
|
@ -17,7 +17,7 @@ const stubSpecies: SpeciesData[] = [
|
||||||
id: 'tajara',
|
id: 'tajara',
|
||||||
name: 'Tajara',
|
name: 'Tajara',
|
||||||
description: '',
|
description: '',
|
||||||
subspeciesLabel: 'Ethnicity',
|
labels: { subspecies: 'Ethnicity' },
|
||||||
subspecies: [
|
subspecies: [
|
||||||
{ id: 'hharar', name: 'Hharar', description: '' },
|
{ id: 'hharar', name: 'Hharar', description: '' },
|
||||||
{ id: 'zhan-khazan', name: 'Zhan-Khazan', description: '' }
|
{ id: 'zhan-khazan', name: 'Zhan-Khazan', description: '' }
|
||||||
|
|
@ -184,6 +184,53 @@ describe('formatFieldOutput', () => {
|
||||||
'Other Skills: Engineering, Medical'
|
'Other Skills: Engineering, Medical'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses species label override for text fields', () => {
|
||||||
|
const field: FieldDef = { label: 'Skin Color', type: 'text' };
|
||||||
|
const sp: SpeciesData[] = [
|
||||||
|
{
|
||||||
|
id: 'tajara',
|
||||||
|
name: 'Tajara',
|
||||||
|
labels: { 'skin-color': 'Fur Colour' },
|
||||||
|
subspecies: [],
|
||||||
|
languages: [],
|
||||||
|
citizenships: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
expect(formatFieldOutput(field, 'Brown', sp, 'tajara')).toBe('Fur Colour: Brown');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default label when species has no override', () => {
|
||||||
|
const field: FieldDef = { label: 'Hair Color', type: 'text' };
|
||||||
|
const sp: SpeciesData[] = [
|
||||||
|
{
|
||||||
|
id: 'tajara',
|
||||||
|
name: 'Tajara',
|
||||||
|
labels: { 'skin-color': 'Fur Colour' },
|
||||||
|
subspecies: [],
|
||||||
|
languages: [],
|
||||||
|
citizenships: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
expect(formatFieldOutput(field, 'Black', sp, 'tajara')).toBe('Hair Color: Black');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default label when useSpeciesLabel flag is false', () => {
|
||||||
|
const field: FieldDef = { label: 'Skin Color', type: 'text' };
|
||||||
|
const sp: SpeciesData[] = [
|
||||||
|
{
|
||||||
|
id: 'tajara',
|
||||||
|
name: 'Tajara',
|
||||||
|
labels: { 'skin-color': 'Fur Colour' },
|
||||||
|
subspecies: [],
|
||||||
|
languages: [],
|
||||||
|
citizenships: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
formatFieldOutput(field, 'Brown', sp, 'tajara', { 'skin-color': { useSpeciesLabel: false } })
|
||||||
|
).toBe('Skin Color: Brown');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const testTemplate: Template = {
|
const testTemplate: Template = {
|
||||||
|
|
|
||||||
|
|
@ -3,44 +3,51 @@ import type { SpeciesData } from './data/types';
|
||||||
import { cmToFeetInches, kgToLb } from './utils/conversions';
|
import { cmToFeetInches, kgToLb } from './utils/conversions';
|
||||||
import { formatICDate } from './utils/dates';
|
import { formatICDate } from './utils/dates';
|
||||||
import { slugify } from './utils/slugify';
|
import { slugify } from './utils/slugify';
|
||||||
|
import { resolveFieldLabel } from './utils/resolve-label';
|
||||||
|
import { getAllFlags, type FieldFlags } from './utils/field-flags';
|
||||||
|
|
||||||
export function formatFieldOutput(
|
export function formatFieldOutput(
|
||||||
field: FieldDef,
|
field: FieldDef,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
speciesData?: SpeciesData[],
|
speciesData?: SpeciesData[],
|
||||||
currentSpecies?: string
|
currentSpecies?: string,
|
||||||
|
flags?: Record<string, FieldFlags>
|
||||||
): string | null {
|
): string | null {
|
||||||
|
const override = speciesData ? resolveFieldLabel(field, speciesData, currentSpecies) : null;
|
||||||
|
const useDynamic = override !== null && (flags?.[slugify(field.label)]?.useSpeciesLabel ?? true);
|
||||||
|
const label = useDynamic ? override : field.label;
|
||||||
|
|
||||||
switch (field.type) {
|
switch (field.type) {
|
||||||
case 'name':
|
case 'name':
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'date':
|
case 'date':
|
||||||
case 'citizenship':
|
case 'citizenship':
|
||||||
return value ? `${field.label}: ${value}` : null;
|
return value ? `${label}: ${value}` : null;
|
||||||
|
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
return value ? `${field.label}:\n${value}` : null;
|
return value ? `${label}:\n${value}` : null;
|
||||||
|
|
||||||
case 'list': {
|
case 'list': {
|
||||||
const lines = splitLines(value as string);
|
const lines = splitLines(value as string);
|
||||||
return lines.length ? `${field.label}:\n${formatBullets(lines)}` : null;
|
return lines.length ? `${label}:\n${formatBullets(lines)}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'number':
|
case 'number':
|
||||||
return value != null && value !== 0 ? `${field.label}: ${value}` : null;
|
return value != null && value !== 0 ? `${label}: ${value}` : null;
|
||||||
|
|
||||||
case 'height':
|
case 'height':
|
||||||
return value ? `${field.label}: ${value} cm (${cmToFeetInches(value as number)})` : null;
|
return value ? `${label}: ${value} cm (${cmToFeetInches(value as number)})` : null;
|
||||||
|
|
||||||
case 'weight': {
|
case 'weight': {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const lb = Math.round(kgToLb(value as number));
|
const lb = Math.round(kgToLb(value as number));
|
||||||
return `${field.label}: ${value} kg (${lb} lb)`;
|
return `${label}: ${value} kg (${lb} lb)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'species': {
|
case 'species': {
|
||||||
if (!value || !speciesData) return null;
|
if (!value || !speciesData) return null;
|
||||||
const sp = speciesData.find((s) => s.id === value);
|
const sp = speciesData.find((s) => s.id === value);
|
||||||
return sp ? `${field.label}: ${sp.name}` : `${field.label}: ${value}`;
|
return sp ? `${label}: ${sp.name}` : `${label}: ${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'subspecies': {
|
case 'subspecies': {
|
||||||
|
|
@ -48,34 +55,37 @@ export function formatFieldOutput(
|
||||||
const sp = speciesData.find((s) => s.id === currentSpecies);
|
const sp = speciesData.find((s) => s.id === currentSpecies);
|
||||||
if (!sp) return null;
|
if (!sp) return null;
|
||||||
const sub = sp.subspecies.find((s) => s.id === value);
|
const sub = sp.subspecies.find((s) => s.id === value);
|
||||||
return sub ? `${sp.subspeciesLabel}: ${sub.name}` : `${sp.subspeciesLabel}: ${value}`;
|
return sub ? `${label}: ${sub.name}` : `${label}: ${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'languages': {
|
case 'languages': {
|
||||||
const arr = value as string[] | undefined;
|
const arr = value as string[] | undefined;
|
||||||
return arr?.length ? `${field.label}: ${arr.join(', ')}` : null;
|
return arr?.length ? `${label}: ${arr.join(', ')}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'checkbox': {
|
case 'checkbox': {
|
||||||
const selected = value as string[] | undefined;
|
const selected = value as string[] | undefined;
|
||||||
if (!selected?.length) return null;
|
if (!selected?.length) return null;
|
||||||
const labels = selected
|
const optLabels = selected
|
||||||
.map((v) => field.options.find((o) => o.value === v)?.label ?? v)
|
.map((v) => field.options.find((o) => o.value === v)?.label ?? v)
|
||||||
return `${field.label}:\n${formatBullets(labels)}`;
|
return `${label}:\n${formatBullets(optLabels)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'select': {
|
case 'select': {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
const opt = field.options.find((o) => o.value === value);
|
const opt = field.options.find((o) => o.value === value);
|
||||||
return `${field.label}: ${opt?.label ?? value}`;
|
return `${label}: ${opt?.label ?? value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'multi-select': {
|
case 'multi-select': {
|
||||||
const vals = value as string[] | undefined;
|
const vals = value as string[] | undefined;
|
||||||
if (!vals?.length) return null;
|
if (!vals?.length) return null;
|
||||||
const labels = vals.map((v) => field.options.find((o) => o.value === v)?.label ?? v);
|
const optLabels = vals.map((v) => field.options.find((o) => o.value === v)?.label ?? v);
|
||||||
return `${field.label}: ${labels.join(', ')}`;
|
return `${label}: ${optLabels.join(', ')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'separator':
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -88,12 +98,13 @@ export function generateRecord(
|
||||||
const publicRecord = template.records.find((r) => r.type === 'public');
|
const publicRecord = template.records.find((r) => r.type === 'public');
|
||||||
const targetRecord = template.records.find((r) => r.type === recordType);
|
const targetRecord = template.records.find((r) => r.type === recordType);
|
||||||
const currentSpecies = data['species'] as string | undefined;
|
const currentSpecies = data['species'] as string | undefined;
|
||||||
|
const flags = getAllFlags(data);
|
||||||
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
|
|
||||||
// Public section
|
// Public section
|
||||||
if (publicRecord) {
|
if (publicRecord) {
|
||||||
const publicLines = renderFields(publicRecord.fields, data, speciesData, currentSpecies);
|
const publicLines = renderFields(publicRecord.fields, data, speciesData, currentSpecies, flags);
|
||||||
if (publicLines.length) {
|
if (publicLines.length) {
|
||||||
parts.push('/// PUBLIC RECORD ///');
|
parts.push('/// PUBLIC RECORD ///');
|
||||||
parts.push(publicLines.join('\n'));
|
parts.push(publicLines.join('\n'));
|
||||||
|
|
@ -102,7 +113,7 @@ export function generateRecord(
|
||||||
|
|
||||||
// Target record section
|
// Target record section
|
||||||
if (targetRecord) {
|
if (targetRecord) {
|
||||||
const bodyLines = renderFields(targetRecord.fields, data, speciesData, currentSpecies);
|
const bodyLines = renderFields(targetRecord.fields, data, speciesData, currentSpecies, flags);
|
||||||
const typeLabel = recordType.toUpperCase();
|
const typeLabel = recordType.toUpperCase();
|
||||||
|
|
||||||
if (!bodyLines.length) {
|
if (!bodyLines.length) {
|
||||||
|
|
@ -124,7 +135,8 @@ function renderFields(
|
||||||
fields: FieldDef[],
|
fields: FieldDef[],
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
speciesData?: SpeciesData[],
|
speciesData?: SpeciesData[],
|
||||||
currentSpecies?: string
|
currentSpecies?: string,
|
||||||
|
flags?: Record<string, FieldFlags>
|
||||||
): string[] {
|
): string[] {
|
||||||
// Split fields into groups by separator boundaries
|
// Split fields into groups by separator boundaries
|
||||||
const groups: FieldDef[][] = [[]];
|
const groups: FieldDef[][] = [[]];
|
||||||
|
|
@ -139,7 +151,7 @@ function renderFields(
|
||||||
const rendered = groups.map((group) => {
|
const rendered = groups.map((group) => {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
for (const field of group) {
|
for (const field of group) {
|
||||||
const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies);
|
const out = formatFieldOutput(field, data[slugify(field.label)], speciesData, currentSpecies, flags);
|
||||||
if (out) lines.push(out);
|
if (out) lines.push(out);
|
||||||
}
|
}
|
||||||
return lines;
|
return lines;
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,9 @@ function zodForField(field: FieldDef): z.ZodTypeAny {
|
||||||
case 'checkbox':
|
case 'checkbox':
|
||||||
case 'languages':
|
case 'languages':
|
||||||
return z.array(z.string()).optional();
|
return z.array(z.string()).optional();
|
||||||
|
|
||||||
|
case 'separator':
|
||||||
|
return z.never();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ function allFields(template: Template) {
|
||||||
function migrateData(char: Character, preset: Template) {
|
function migrateData(char: Character, preset: Template) {
|
||||||
for (const record of preset.records) {
|
for (const record of preset.records) {
|
||||||
for (const field of record.fields) {
|
for (const field of record.fields) {
|
||||||
if (!field.from) continue;
|
if (field.type === 'separator' || !field.from) continue;
|
||||||
const newKey = slugify(field.label);
|
const newKey = slugify(field.label);
|
||||||
if (char.data[newKey] !== undefined) continue;
|
if (char.data[newKey] !== undefined) continue;
|
||||||
const oldNames = field.from.split(',').map((s) => s.trim());
|
const oldNames = field.from.split(',').map((s) => s.trim());
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import type { Character } from '../types';
|
import type { Character } from '../types';
|
||||||
|
import { FLAGS_KEY } from './field-flags';
|
||||||
|
|
||||||
export function isBlankCharacter(char: Character): boolean {
|
export function isBlankCharacter(char: Character): boolean {
|
||||||
if (!char.data) return true;
|
if (!char.data) return true;
|
||||||
for (const value of Object.values(char.data)) {
|
for (const [key, value] of Object.entries(char.data)) {
|
||||||
|
if (key === FLAGS_KEY) continue;
|
||||||
if (value === '' || value === undefined || value === null || value === 0) continue;
|
if (value === '' || value === undefined || value === null || value === 0) continue;
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (value.length === 0) continue;
|
if (value.length === 0) continue;
|
||||||
|
|
|
||||||
44
src/lib/utils/field-flags.ts
Normal file
44
src/lib/utils/field-flags.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
export const FLAGS_KEY = '__flags';
|
||||||
|
|
||||||
|
export interface FieldFlags {
|
||||||
|
useSpeciesLabel?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS: Required<FieldFlags> = {
|
||||||
|
useSpeciesLabel: true,
|
||||||
|
hidden: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFlag(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
fieldKey: string,
|
||||||
|
flag: keyof FieldFlags
|
||||||
|
): boolean {
|
||||||
|
const all = data[FLAGS_KEY] as Record<string, FieldFlags> | undefined;
|
||||||
|
return all?.[fieldKey]?.[flag] ?? DEFAULTS[flag];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFlag(
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
fieldKey: string,
|
||||||
|
flag: keyof FieldFlags,
|
||||||
|
value: boolean
|
||||||
|
): void {
|
||||||
|
if (!data[FLAGS_KEY]) data[FLAGS_KEY] = {};
|
||||||
|
const all = data[FLAGS_KEY] as Record<string, FieldFlags>;
|
||||||
|
if (!all[fieldKey]) all[fieldKey] = {};
|
||||||
|
if (value === DEFAULTS[flag]) {
|
||||||
|
delete all[fieldKey][flag];
|
||||||
|
if (Object.keys(all[fieldKey]).length === 0) delete all[fieldKey];
|
||||||
|
if (Object.keys(all).length === 0) delete data[FLAGS_KEY];
|
||||||
|
} else {
|
||||||
|
all[fieldKey][flag] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllFlags(
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Record<string, FieldFlags> {
|
||||||
|
return (data[FLAGS_KEY] as Record<string, FieldFlags>) ?? {};
|
||||||
|
}
|
||||||
37
src/lib/utils/resolve-label.test.ts
Normal file
37
src/lib/utils/resolve-label.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { resolveFieldLabel } from './resolve-label';
|
||||||
|
import type { FieldDef } from '$lib/types';
|
||||||
|
import type { SpeciesData } from '$lib/data/types';
|
||||||
|
|
||||||
|
const stubSpecies: SpeciesData[] = [
|
||||||
|
{
|
||||||
|
id: 'tajara',
|
||||||
|
name: 'Tajara',
|
||||||
|
labels: { subspecies: 'Ethnicity', 'skin-color': 'Fur Colour' },
|
||||||
|
subspecies: [{ id: 'hharar', name: 'Hharar' }],
|
||||||
|
languages: [],
|
||||||
|
citizenships: []
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('resolveFieldLabel', () => {
|
||||||
|
it('returns species override when available', () => {
|
||||||
|
const field: FieldDef = { label: 'Skin Color', type: 'text' };
|
||||||
|
expect(resolveFieldLabel(field, stubSpecies, 'tajara')).toBe('Fur Colour');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no species is selected', () => {
|
||||||
|
const field: FieldDef = { label: 'Skin Color', type: 'text' };
|
||||||
|
expect(resolveFieldLabel(field, stubSpecies, undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when species has no label for the field', () => {
|
||||||
|
const field: FieldDef = { label: 'Height', type: 'height' };
|
||||||
|
expect(resolveFieldLabel(field, stubSpecies, 'tajara')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for separator fields', () => {
|
||||||
|
const field: FieldDef = { label: 'Appearance', type: 'separator' };
|
||||||
|
expect(resolveFieldLabel(field, stubSpecies, 'tajara')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
src/lib/utils/resolve-label.ts
Normal file
17
src/lib/utils/resolve-label.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import type { FieldDef } from '$lib/types';
|
||||||
|
import type { SpeciesData } from '$lib/data/types';
|
||||||
|
import { slugify } from './slugify';
|
||||||
|
|
||||||
|
export function resolveFieldLabel(
|
||||||
|
field: FieldDef,
|
||||||
|
speciesData: SpeciesData[],
|
||||||
|
currentSpeciesId: string | undefined
|
||||||
|
): string | null {
|
||||||
|
if (field.type === 'separator') return null;
|
||||||
|
if (!currentSpeciesId) return null;
|
||||||
|
|
||||||
|
const sp = speciesData.find((s) => s.id === currentSpeciesId);
|
||||||
|
if (!sp) return null;
|
||||||
|
|
||||||
|
return sp.labels[slugify(field.label)] ?? null;
|
||||||
|
}
|
||||||
|
|
@ -22,7 +22,7 @@ export function diffTemplates(old: Template, current: Template): TemplateDiff {
|
||||||
|
|
||||||
for (const record of current.records) {
|
for (const record of current.records) {
|
||||||
for (const field of record.fields) {
|
for (const field of record.fields) {
|
||||||
if (!field.from) continue;
|
if (field.type === 'separator' || !field.from) continue;
|
||||||
const fromNames = field.from.split(',').map((s) => s.trim());
|
const fromNames = field.from.split(',').map((s) => s.trim());
|
||||||
const match = fromNames.find((f) => oldFields.has(f));
|
const match = fromNames.find((f) => oldFields.has(f));
|
||||||
if (match && !newFields.has(match)) {
|
if (match && !newFields.has(match)) {
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@
|
||||||
New Character
|
New Character
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => emptyFileInput.click()}
|
onclick={() => emptyFileInput!.click()}
|
||||||
class="px-4 py-2 rounded text-sm border hover:opacity-80"
|
class="px-4 py-2 rounded text-sm border hover:opacity-80"
|
||||||
style="border-color: var(--border); color: var(--text-muted);"
|
style="border-color: var(--border); color: var(--text-muted);"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue