diff --git a/src/vcen-edit/App.svelte b/src/vcen-edit/App.svelte index b6946f4..3ad32a2 100644 --- a/src/vcen-edit/App.svelte +++ b/src/vcen-edit/App.svelte @@ -6,6 +6,8 @@ const demoCat: Cat = { id: 'ct_demo' as Cat['id'], name: 'Catálogo demo', + lang: 'es', + langs: ['es'], atts: [], opts: [], optCategories: [], diff --git a/src/vcen-edit/canvas/Editor.svelte b/src/vcen-edit/canvas/Editor.svelte index c1ca4d5..f2aa05f 100644 --- a/src/vcen-edit/canvas/Editor.svelte +++ b/src/vcen-edit/canvas/Editor.svelte @@ -14,6 +14,8 @@ return { id: toCatID('test'), name: 'Nuevo', + lang: 'es', + langs: ['es'], optCategories: [], opts: [], atts: [], diff --git a/src/vcen-edit/comps/AttDetail.svelte b/src/vcen-edit/comps/AttDetail.svelte index eee1e61..8b25673 100644 --- a/src/vcen-edit/comps/AttDetail.svelte +++ b/src/vcen-edit/comps/AttDetail.svelte @@ -1,13 +1,16 @@ -un {#if ctx} {@const { att, isCatLevel, objId, secId, attId, container } = ctx} +
+

- {att.name || 'Sin nombre'} + {labelOf(att.label, cat.lang) || 'Sin nombre'}

{#if computedState} @@ -170,19 +196,29 @@ const stateLabel: Record = { → Disponible {/if} {att.id} +

Identificación

-
- - patch('name', e.currentTarget.value)} - placeholder="Color, Suelo, Material..." - class="field-input" /> -
+ { + if (isCatLevel) editor.setCatAttLabel(attId, label as any) + else editor.setAttLabel(objId!, secId, attId, label as any) + }} + />
@@ -483,4 +519,29 @@ const stateLabel: Record = {
+
+ +
+ + + {#if showDeleteModal} +
showDeleteModal = false}> +
e.stopPropagation()}> +

¿Eliminar atributo?

+

{labelOf(att.label, cat.lang) || att.id}

+
+ + +
+
+
+ {/if} {/if} diff --git a/src/vcen-edit/comps/AttList.svelte b/src/vcen-edit/comps/AttList.svelte index 4f3b384..9db6408 100644 --- a/src/vcen-edit/comps/AttList.svelte +++ b/src/vcen-edit/comps/AttList.svelte @@ -1,6 +1,7 @@
-
-
- - Atributos ({atts.length}) - - -
-
- {#if atts.length === 0} -
- Sin atributos globales - -
- {:else} - {#each atts as att (att.id)} - {@const isActive = attId === att.id} - -
- + + { creating = true; newAttName = '' }}> + {#if atts.length === 0 && !creating} +
+ Sin atributos globales + +
+ {:else} + {#each atts as att (att.id)} + {@const isActive = attId === att.id} + + {/each} + {/if} + {#if creating} +
+ { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }} + placeholder="Nombre del atributo" + class="field-input text-xs" + autofocus + /> +
+ at: { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }} - placeholder="Nombre del atributo" - class="field-input text-xs" - autofocus + bind:value={newAttSlug} + oninput={() => { attSlugError = '' }} + onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }} + placeholder="slug-atributo" + class="field-input text-xs font-mono flex-1" /> -
- at: - { attSlugError = '' }} - onkeydown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') cancelAdd() }} - placeholder="slug-atributo" - class="field-input text-xs font-mono flex-1" - /> -
- {#if attSlugError} -

{attSlugError}

- {/if} -
- - -
- {/if} -
-
+ {#if attSlugError} +

{attSlugError}

+ {/if} +
+ + +
+
+ {/if} + + +
{#if attId} diff --git a/src/vcen-edit/comps/CatMetaPanel.svelte b/src/vcen-edit/comps/CatMetaPanel.svelte index 9998a6c..7618db7 100644 --- a/src/vcen-edit/comps/CatMetaPanel.svelte +++ b/src/vcen-edit/comps/CatMetaPanel.svelte @@ -1,6 +1,6 @@ +
@@ -24,10 +48,10 @@ const stats = $derived({
{#each [ - { label: 'Objetos', value: stats.objs }, + { label: 'Objetos', value: stats.objs }, { label: 'Secciones', value: stats.secs }, { label: 'Atributos', value: stats.atts }, - { label: 'Opciones', value: stats.opts }, + { label: 'Opciones', value: stats.opts }, ] as s}
{s.value} @@ -36,7 +60,7 @@ const stats = $derived({ {/each}
- +

Identificación

@@ -48,4 +72,52 @@ const stats = $derived({
+ +
+

Idioma principal

+

Idioma por defecto del catálogo. Usado como fallback en campos multilingüe.

+
+ {#each SUPPORTED_LOCALES as loc} + + {/each} +
+
+ + +
+

Idiomas soportados

+

Idiomas adicionales disponibles en campos multilingüe. El idioma principal siempre está incluido.

+
+ {#each SUPPORTED_LOCALES.filter(l => l.code !== cat.lang) as loc} + {@const active = cat.langs.includes(loc.code)} + + {/each} +
+ {#if cat.langs.length > 1} +

+ {cat.langs.length} idiomas: {cat.langs.join(', ')} +

+ {/if} +
+ +
diff --git a/src/vcen-edit/comps/CatalogEditor.svelte b/src/vcen-edit/comps/CatalogEditor.svelte index e7b4c48..03444be 100644 --- a/src/vcen-edit/comps/CatalogEditor.svelte +++ b/src/vcen-edit/comps/CatalogEditor.svelte @@ -1,8 +1,10 @@ diff --git a/src/vcen-edit/comps/CatalogLayout.svelte b/src/vcen-edit/comps/CatalogLayout.svelte index 907668b..b53a6a5 100644 --- a/src/vcen-edit/comps/CatalogLayout.svelte +++ b/src/vcen-edit/comps/CatalogLayout.svelte @@ -1,7 +1,7 @@ + +
+
+ +
+ + + + {#snippet actions()} +
+
+ { drawerType = type; drawerTargetPath = addTargetPath; drawerOpen = true }} iconOnly={true} {glob} placement="start" /> +
+ {/snippet} + + {#if keyOrder.length === 0} +
+ + + + +

{glob.t('vcen.editor.mtas.field.empty')}

+
+ {:else} +
+ selectedPath = p} + onMove={(p, dir) => moveFieldAtPath(p, dir)} + /> +
+ {/if} +
+ + +
+ {#if selectedField && selectedPath.length > 0} + +
+
+ {#each selectedPath as seg, i} + {#if i > 0}/{/if} + {seg} + {/each} +
+ +
+
+ renameFieldAtPath(selectedPath, newKey)} + onFieldChange={fld => setFieldAtPath(selectedPath, fld)} + /> +
+ {:else} +
+ + + +

{glob.t('vcen.editor.mtas.tree.selectHint')}

+
+ {/if} +
+ +
+ + + {#if !validation.valid} +
+
+ + + +
+ {glob.t('vcen.editor.mtas.validation.errorsTitle')} + {#each validation.errors as error} + · {error} + {/each} +
+
+
+ {/if} + +
+
+ + + { addFieldAtPath(drawerTargetPath, key, fld); selectedPath = [...drawerTargetPath, key] }} + onClose={() => drawerOpen = false} +/> + + +{#if showDeleteConfirm && selectedField} +
+ +
+
+
+ + + +
+
+

{glob.t('vcen.editor.mtas.field.deleteConfirm')}

+

{glob.t('vcen.editor.mtas.field.deleteConfirmMsg')}

+
+ + {selectedKey} +
+
+
+
+ + +
+
+
+{/if} diff --git a/src/vcen-edit/comps/EditorTreeNodes.svelte b/src/vcen-edit/comps/EditorTreeNodes.svelte new file mode 100644 index 0000000..a7e5c84 --- /dev/null +++ b/src/vcen-edit/comps/EditorTreeNodes.svelte @@ -0,0 +1,120 @@ + + +{#each order as key, i (key)} + {@const field = asField(meta[key])} + {@const np = nodePath(key)} + {@const vResult = validateField(key, field)} + {@const sel = isSelected(key)} + {@const anc = isAncestor(key)} + {@const isGroup = field.type === 'group'} + {@const groupOpen = openGroups.has(key)} + {@const childKeys = isGroup ? Object.keys(field.value) : []} + +
onSelect(np)} + onkeydown={e => { if (e.key === 'Enter') onSelect(np) }} + > + {#if isGroup} + + {:else} + + {/if} + +
+ + + + + {key} + + +
+ + +
+
+ + {#if isGroup && groupOpen && childKeys.length > 0} + + {/if} + + {#if isGroup && groupOpen && childKeys.length === 0} +
+ {glob.t('vcen.editor.mtas.field.emptyGroup')} +
+ {/if} +{/each} diff --git a/src/vcen-edit/comps/LingInput.svelte b/src/vcen-edit/comps/LingInput.svelte new file mode 100644 index 0000000..1dcf8dd --- /dev/null +++ b/src/vcen-edit/comps/LingInput.svelte @@ -0,0 +1,190 @@ + + +
+ + {#if label} +
+ + {#if locales.length > 1} + + {#if invalid}requerido{:else if isOptional}opcional{:else}requerido{/if} + + {/if} +
+ {/if} + +
+ + + {#if locales.length > 1} +
+
+ {#each locales as locale (locale)} + {#if localeTab} + {@render localeTab({ + locale, + active: currentLocale === locale, + hasContent: hasContent(locale), + activate: () => activateLocale(locale), + })} + {:else} + {@const active = currentLocale === locale} + {@const filled = hasContent(locale)} + + {/if} + {/each} +
+ +
+ + {charCount > 0 ? charCount : ''} + +
+
+ +
+ {/if} + + {#if asType === 'textarea'} + + {:else} + + {/if} + + {#if invalid} +
+ + + + + El campo {defaultLocale.toUpperCase()} es obligatorio + +
+ {/if} + +
+
diff --git a/src/vcen-edit/comps/MetaPanel.svelte b/src/vcen-edit/comps/MetaPanel.svelte new file mode 100644 index 0000000..258e482 --- /dev/null +++ b/src/vcen-edit/comps/MetaPanel.svelte @@ -0,0 +1,25 @@ + + +
+ onUpdate(Object.keys(m).filter(k => k !== 'order').length === 0 ? undefined : m)} + /> +
diff --git a/src/vcen-edit/comps/MetadataPanel.svelte b/src/vcen-edit/comps/MetadataPanel.svelte new file mode 100644 index 0000000..083a751 --- /dev/null +++ b/src/vcen-edit/comps/MetadataPanel.svelte @@ -0,0 +1,54 @@ + + +{#if ctx} +
+
+

Metadatos — {ctx.title}

+

{ctx.id}

+
+
+ +
+
+{/if} diff --git a/src/vcen-edit/comps/ObjAttsPanel.svelte b/src/vcen-edit/comps/ObjAttsPanel.svelte index 093963e..f5bae23 100644 --- a/src/vcen-edit/comps/ObjAttsPanel.svelte +++ b/src/vcen-edit/comps/ObjAttsPanel.svelte @@ -1,15 +1,17 @@ {#if obj && objId} -
+
+
-

{obj.name || 'Sin nombre'}

+

{labelOf(obj.label, cat.lang) || 'Sin nombre'}

{#if computedState} @@ -61,13 +65,15 @@ const stateLabel: Record = {

Identificación

-
- - editor.renameObj(objId, e.currentTarget.value)} - placeholder="Habitación, Cocina..." - class="field-input" /> -
+ editor.setObjLabel(objId, label as any)} + />
= { {/if}
+
{/if} diff --git a/src/vcen-edit/comps/ObjList.svelte b/src/vcen-edit/comps/ObjList.svelte index 3f89ac7..fb811a3 100644 --- a/src/vcen-edit/comps/ObjList.svelte +++ b/src/vcen-edit/comps/ObjList.svelte @@ -1,6 +1,7 @@ + +
+
+ + {title} ({count}) + + {#if actions} + {@render actions()} + {:else if onAdd} + + {/if} +
+ {#if extra} + {@render extra()} + {/if} +
+ {@render children?.()} +
+
diff --git a/src/vcen-edit/comps/catalog-editor.svelte.ts b/src/vcen-edit/comps/catalog-editor.svelte.ts deleted file mode 100644 index 33b0f16..0000000 --- a/src/vcen-edit/comps/catalog-editor.svelte.ts +++ /dev/null @@ -1,709 +0,0 @@ -import type { Cat, Obj, Sec, Att, AttDomain, AttValue, - Opt, OptCategory, AttOpt, - OptID, OptCatID, RuleSet, Rule, RuleAction, - RuleID, AttState, RelativePath, - DomainValueRef, DomainValueState, - RuleLevel, CatID, ObjID, SecID, AttID } from '@/vcen/lib'; -import { toAttID, toObjID, toOptID, toRuleID, toSecID } from '@/vcen/lib/node_util'; -import { catOptIDPrefix } from '@/vcen/lib/types/opt'; -import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'; - -// ───────────────────────────────────────────────────────────────────────────── -// SELECTION — nodo activo en el árbol -// ───────────────────────────────────────────────────────────────────────────── - -export type SelectionKind = - | 'cat-info' | 'cat-atts' | 'opts' - | 'obj-info' | 'obj-atts' - | 'sec-info' | 'sec-atts'; - -export type Selection = - | { kind: 'cat-info' } - | { kind: 'cat-atts'; attId?: AttID } - | { kind: 'opts' } - | { kind: 'obj-info'; objId: ObjID } - | { kind: 'obj-atts'; objId: ObjID; attId?: AttID } - | { kind: 'sec-info'; objId: ObjID; secId: SecID } - | { kind: 'sec-atts'; objId: ObjID; secId: SecID; attId?: AttID }; - -// ───────────────────────────────────────────────────────────────────────────── -// ID GENERATORS -// ───────────────────────────────────────────────────────────────────────────── - -let _seq = Date.now(); -function nextSeq(): string { return (++_seq).toString(36); } - -// ───────────────────────────────────────────────────────────────────────────── -// DEFAULT FACTORIES -// ───────────────────────────────────────────────────────────────────────────── - -function defaultAttValue(): AttValue { - return { domain: { kind: 'string', mode: 'input' }, value: '' }; -} - -function defaultAtt(name = 'Nuevo atributo', slug?: string): Att { - const id = toAttID(slug ?? nextSeq()); - return { id, name, value: defaultAttValue() }; -} - -function defaultSec(name = 'Nueva sección', slug?: string): Sec { - const id = toSecID(slug ?? nextSeq()); - return { id, name, atts: [] }; -} - -function defaultObj(name = 'Nuevo objeto', slug?: string): Obj { - const id = toObjID(slug ?? nextSeq()); - return { id, name, atts: [], secs: [] }; -} - -function defaultOpt(value = ''): Opt { - return { - id: toOptID(nextSeq()), - value: { kind: 'string', value }, - }; -} - -function defaultOptCategory(name = 'Nueva categoría'): OptCategory { - return { id: `${catOptIDPrefix}_${nextSeq()}` as OptCatID, name }; -} - -function defaultRule(): Rule { - return { - id: toRuleID(nextSeq()), - priority: 10, - condition: true, - actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }], - }; -} - -// ───────────────────────────────────────────────────────────────────────────── -// STORE -// ───────────────────────────────────────────────────────────────────────────── - -/** - * Store Svelte 5 para el editor de catálogo. - * - * Gestiona el Cat en modo borrador con mutaciones granulares. - * La UI lee `cat` directamente (reactivo) y llama a las mutaciones - * para cada operación de edición. - * - * ## Uso - * - * ```svelte - * - * - * - * ``` - */ -export function createCatalogEditorStore(initial: Cat) { - let cat = $state(JSON.parse(JSON.stringify(initial))); - let selection = $state({ kind: 'cat-info' }); - let dirty = $state(false); - let rev = $state(0); // incrementa en cada mutación — fuerza reactividad global - - // Estados calculados por reglas. - // Usamos $effect + $state en lugar de $derived.by para garantizar que - // el efecto se re-ejecuta ante cualquier cambio profundo de `cat` - // (incluyendo att.value.value dentro de buildEvalCtx). - let objStates = $state(new Map()) - let secStates = $state(new Map()) - - /** Valor efectivo de un atributo (mode:fixed → domain.fixed, resto → value.value). */ - function attVal(att: Att): unknown { - const d = att.value.domain as any - return d?.mode === 'fixed' ? (d.fixed ?? att.value.value) : att.value.value - } - - $effect(() => { - const catCtx: Record = {} - for (const att of cat.atts) catCtx[att.id] = attVal(att) - - const m = new Map() - for (const obj of cat.objs) { - if (!obj.rules?.rules?.length) { m.set(obj.id, null); continue } - const ctx: Record = { cat: catCtx } - for (const a of obj.atts) ctx[a.id] = attVal(a) - for (const sec of obj.secs) { - ctx[sec.id] = {} - for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a) - } - m.set(obj.id, evalRuleSetState(obj.rules, ctx)) - } - objStates = m - }) - - $effect(() => { - const catCtx: Record = {} - for (const att of cat.atts) catCtx[att.id] = attVal(att) - - const m = new Map() - for (const obj of cat.objs) { - for (const sec of obj.secs) { - if (!sec.rules?.rules?.length) { m.set(sec.id, null); continue } - const ctx: Record = { cat: catCtx } - for (const a of obj.atts) ctx[a.id] = attVal(a) - ctx[sec.id] = {} - for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a) - m.set(sec.id, evalRuleSetState(sec.rules, ctx)) - } - } - secStates = m - }) - - // ── Helpers internos ──────────────────────────────────────────────────── - - function mutate(fn: () => void) { - fn(); - dirty = true; - rev++; - } - - function findObj(objId: ObjID): Obj | undefined { - return cat.objs.find(o => o.id === objId); - } - - function findSec(objId: ObjID, secId: SecID): Sec | undefined { - return findObj(objId)?.secs.find(s => s.id === secId); - } - - function findAtt(objId: ObjID, secId: SecID | undefined, attId: AttID): Att | undefined { - if (secId) return findSec(objId, secId)?.atts.find(a => a.id === attId); - return findObj(objId)?.atts.find(a => a.id === attId); - } - - function findCatAtt(attId: AttID): Att | undefined { - return cat.atts.find(a => a.id === attId); - } - - // ── Selección ─────────────────────────────────────────────────────────── - - function select(s: Selection) { - selection = s; - } - - // ── Cat ───────────────────────────────────────────────────────────────── - - function renameCat(name: string) { - mutate(() => { cat.name = name; }); - } - - // ── Obj ───────────────────────────────────────────────────────────────── - - function addObj(name?: string, slug?: string): ObjID { - const obj = defaultObj(name, slug); - mutate(() => { cat.objs.push(obj); }); - select({ kind: 'obj-info', objId: obj.id }); - return obj.id; - } - - function renameObj(objId: ObjID, name: string) { - mutate(() => { - const obj = findObj(objId); - if (obj) obj.name = name; - }); - } - - function removeObj(objId: ObjID) { - mutate(() => { - cat.objs = cat.objs.filter(o => o.id !== objId); - }); - // Limpiar selección si apuntaba al obj eliminado - const s = selection; - if ( - (s.kind === 'obj-info' || s.kind === 'obj-atts') && s.objId === objId || - (s.kind === 'sec-info' || s.kind === 'sec-atts') && s.objId === objId - ) { - select({ kind: 'cat-info' }); - } - } - - function moveObj(objId: ObjID, direction: 'up' | 'down') { - mutate(() => { - const idx = cat.objs.findIndex(o => o.id === objId); - if (idx === -1) return; - const target = direction === 'up' ? idx - 1 : idx + 1; - if (target < 0 || target >= cat.objs.length) return; - [cat.objs[idx], cat.objs[target]] = [cat.objs[target], cat.objs[idx]]; - }); - } - - // ── Sec ───────────────────────────────────────────────────────────────── - - function addSec(objId: ObjID, name?: string, slug?: string): SecID { - const sec = defaultSec(name, slug); - mutate(() => { - const obj = findObj(objId); - if (obj) obj.secs.push(sec); - }); - select({ kind: 'sec-info', objId, secId: sec.id }); - return sec.id; - } - - function renameSec(objId: ObjID, secId: SecID, name: string) { - mutate(() => { - const sec = findSec(objId, secId); - if (sec) sec.name = name; - }); - } - - function removeSec(objId: ObjID, secId: SecID) { - mutate(() => { - const obj = findObj(objId); - if (obj) obj.secs = obj.secs.filter(s => s.id !== secId); - }); - const s = selection; - if ( - (s.kind === 'sec-info' || s.kind === 'sec-atts') && s.secId === secId - ) { - select({ kind: 'obj-atts', objId }); - } - } - - function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') { - mutate(() => { - const obj = findObj(objId); - if (!obj) return; - const idx = obj.secs.findIndex(s => s.id === secId); - if (idx === -1) return; - const target = direction === 'up' ? idx - 1 : idx + 1; - if (target < 0 || target >= obj.secs.length) return; - [obj.secs[idx], obj.secs[target]] = [obj.secs[target], obj.secs[idx]]; - }); - } - - function setSecRuleSet(objId: ObjID, secId: SecID, ruleSet: RuleSet | undefined) { - mutate(() => { - const sec = findSec(objId, secId); - if (sec) sec.rules = ruleSet; - }); - } - - // ── Att (en Obj o Sec) ────────────────────────────────────────────────── - - function addAtt(objId: ObjID, secId: SecID | undefined, name?: string, slug?: string): AttID { - const att = defaultAtt(name, slug); - mutate(() => { - if (secId) { - const sec = findSec(objId, secId); - if (sec) sec.atts.push(att); - } else { - const obj = findObj(objId); - if (obj) obj.atts.push(att); - } - }); - if (secId) { - select({ kind: 'sec-atts', objId, secId, attId: att.id }); - } else { - select({ kind: 'obj-atts', objId, attId: att.id }); - } - return att.id; - } - - function addCatAtt(name?: string, slug?: string): AttID { - const att = defaultAtt(name, slug); - mutate(() => { cat.atts.push(att); }); - select({ kind: 'cat-atts', attId: att.id }); - return att.id; - } - - function renameAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, name: string) { - mutate(() => { - const att = findAtt(objId, secId, attId); - if (att) att.name = name; - }); - } - - function renameCatAtt(attId: AttID, name: string) { - mutate(() => { - const att = findCatAtt(attId); - if (att) att.name = name; - }); - } - - function removeAtt(objId: ObjID, secId: SecID | undefined, attId: AttID) { - mutate(() => { - if (secId) { - const sec = findSec(objId, secId); - if (sec) sec.atts = sec.atts.filter(a => a.id !== attId); - } else { - const obj = findObj(objId); - if (obj) obj.atts = obj.atts.filter(a => a.id !== attId); - } - }); - const s = selection; - if (s.kind === 'obj-atts' && s.attId === attId) { - select({ kind: 'obj-atts', objId }); - } else if (s.kind === 'sec-atts' && s.attId === attId) { - select({ kind: 'sec-atts', objId, secId: secId! }); - } - } - - function removeCatAtt(attId: AttID) { - mutate(() => { - cat.atts = cat.atts.filter(a => a.id !== attId); - }); - const s = selection; - if (s.kind === 'cat-atts' && s.attId === attId) { - select({ kind: 'cat-atts' }); - } - } - - function moveAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, direction: 'up' | 'down') { - mutate(() => { - const list = secId ? findSec(objId, secId)?.atts : findObj(objId)?.atts; - if (!list) return; - const idx = list.findIndex(a => a.id === attId); - if (idx === -1) return; - const target = direction === 'up' ? idx - 1 : idx + 1; - if (target < 0 || target >= list.length) return; - [list[idx], list[target]] = [list[target], list[idx]]; - }); - } - - function updateAttDomain(objId: ObjID, secId: SecID | undefined, attId: AttID, domain: AttDomain) { - mutate(() => { - const att = findAtt(objId, secId, attId); - if (!att) return; - // Cambiar dominio resetea el valor al default del nuevo kind - att.value = { domain, value: defaultValueForDomain(domain) } as AttValue; - }); - } - - function updateCatAttDomain(attId: AttID, domain: AttDomain) { - mutate(() => { - const att = findCatAtt(attId); - if (!att) return; - att.value = { domain, value: defaultValueForDomain(domain) } as AttValue; - }); - } - - function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) { - mutate(() => { - const att = findAtt(objId, secId, attId); - if (att) att.value = { ...att.value, value } as typeof att.value; - }); - } - - function updateCatAttDefaultValue(attId: AttID, value: unknown) { - mutate(() => { - const att = findCatAtt(attId); - if (att) att.value = { ...att.value, value } as typeof att.value; - }); - } - - function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) { - mutate(() => { - const att = findAtt(objId, secId, attId); - if (att) att.rules = ruleSet; - }); - } - - function setCatAttRuleSet(attId: AttID, ruleSet: RuleSet | undefined) { - mutate(() => { - const att = findCatAtt(attId); - if (att) att.rules = ruleSet; - }); - } - - function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) { - mutate(() => { - const obj = findObj(objId); - if (obj) obj.rules = ruleSet; - }); - } - - // ── Opts globales ─────────────────────────────────────────────────────── - - function addOpt(value = '', categoryId?: OptCatID): OptID { - const opt = defaultOpt(value); - if (categoryId) opt.categoryId = categoryId; - mutate(() => { cat.opts.push(opt); }); - return opt.id; - } - - function updateOpt(optId: OptID, patch: Partial>) { - mutate(() => { - const opt = cat.opts.find(o => o.id === optId); - if (!opt) return; - if (patch.value !== undefined) opt.value = patch.value; - if (patch.label !== undefined) opt.label = patch.label; - if (patch.categoryId !== undefined) opt.categoryId = patch.categoryId; - if (patch.rules !== undefined) opt.rules = patch.rules; - }); - } - - function removeOpt(optId: OptID) { - mutate(() => { - cat.opts = cat.opts.filter(o => o.id !== optId); - // Limpiar referencias en dominios option/list de todos los atts - removeOptFromAllDomains(optId); - }); - } - - function removeOptFromAllDomains(optId: OptID) { - // Cat-level atts - for (const att of cat.atts) pruneAttOpt(att, optId); - // Obj/Sec atts - for (const obj of cat.objs) { - for (const att of obj.atts) pruneAttOpt(att, optId); - for (const sec of obj.secs) { - for (const att of sec.atts) pruneAttOpt(att, optId); - } - } - } - - function pruneAttOpt(att: Att, optId: OptID) { - if (att.value.domain.kind === 'option' && att.value.domain.mode === 'list') { - (att.value.domain as any).list = (att.value.domain as any).list.filter( - (ao: AttOpt) => ao.optId !== optId - ); - } - } - - function addAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) { - mutate(() => { - const att = findAtt(objId, secId, attId); - if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return; - const list = (att.value.domain as any).list as AttOpt[]; - if (!list.find(ao => ao.optId === optId)) { - list.push({ optId }); - } - }); - } - - function removeAttOpt(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID) { - mutate(() => { - const att = findAtt(objId, secId, attId); - if (!att || att.value.domain.kind !== 'option' || att.value.domain.mode !== 'list') return; - (att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[]) - .filter(ao => ao.optId !== optId); - }); - } - - function addCatAttOpt(attId: AttID, optId: OptID) { - mutate(() => { - const att = findCatAtt(attId) - if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return - const list = (att.value.domain as any).list as AttOpt[] - if (!list.find((ao: AttOpt) => ao.optId === optId)) list.push({ optId }) - }) - } - - function removeCatAttOpt(attId: AttID, optId: OptID) { - mutate(() => { - const att = findCatAtt(attId) - if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return - ;(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[]).filter((ao: AttOpt) => ao.optId !== optId) - }) - } - - function updateAttOptLabel(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID, label: string) { - mutate(() => { - const att = findAtt(objId, secId, attId) - if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return - const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId) - if (attOpt) attOpt.label = label || undefined - }) - } - - function updateCatAttOptLabel(attId: AttID, optId: OptID, label: string) { - mutate(() => { - const att = findCatAtt(attId) - if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return - const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId) - if (attOpt) attOpt.label = label || undefined - }) - } - - // ── OptCategories ─────────────────────────────────────────────────────── - - function addOptCategory(name?: string): OptCatID { - const oc = defaultOptCategory(name); - mutate(() => { cat.optCategories.push(oc); }); - return oc.id; - } - - function renameOptCategory(ocId: OptCatID, name: string) { - mutate(() => { - const oc = cat.optCategories.find(c => c.id === ocId); - if (oc) oc.name = name; - }); - } - - function removeOptCategory(ocId: OptCatID) { - mutate(() => { - cat.optCategories = cat.optCategories.filter(c => c.id !== ocId); - // Opts que apuntaban a esta categoría quedan sin categoría - for (const opt of cat.opts) { - if (opt.categoryId === ocId) delete opt.categoryId; - } - }); - } - - // ── RuleSet helpers ───────────────────────────────────────────────────── - - function addRule(ruleSet: RuleSet): RuleID { - const rule = defaultRule(); - mutate(() => { ruleSet.rules.push(rule); }); - return rule.id; - } - - function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial) { - mutate(() => { - const rule = ruleSet.rules.find(r => r.id === ruleId); - if (!rule) return; - if (patch.condition !== undefined) rule.condition = patch.condition; - if (patch.priority !== undefined) rule.priority = patch.priority; - if ((patch as any).label !== undefined) (rule as any).label = (patch as any).label; - }); - } - - function removeRule(ruleSet: RuleSet, ruleId: RuleID) { - mutate(() => { - ruleSet.rules = ruleSet.rules.filter(r => r.id !== ruleId); - }); - } - - function addAction(ruleSet: RuleSet, ruleId: RuleID, action: RuleAction) { - mutate(() => { - const rule = ruleSet.rules.find(r => r.id === ruleId); - if (rule) rule.actions.push(action); - }); - } - - function updateAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number, action: RuleAction) { - mutate(() => { - const rule = ruleSet.rules.find(r => r.id === ruleId); - if (rule && actionIdx < rule.actions.length) rule.actions[actionIdx] = action; - }); - } - - function removeAction(ruleSet: RuleSet, ruleId: RuleID, actionIdx: number) { - mutate(() => { - const rule = ruleSet.rules.find(r => r.id === ruleId); - if (rule) rule.actions.splice(actionIdx, 1); - }); - } - - // ── Slug validators ───────────────────────────────────────────────────── - - function isObjSlugTaken(slug: string): boolean { - return cat.objs.some(o => o.id === toObjID(slug)); - } - - function isSecSlugTaken(slug: string): boolean { - return cat.objs.some(o => o.secs.some(s => s.id === toSecID(slug))); - } - - function isAttSlugTaken(slug: string): boolean { - const id = toAttID(slug); - if (cat.atts.some(a => a.id === id)) return true; - return cat.objs.some(o => - o.atts.some(a => a.id === id) || - o.secs.some(s => s.atts.some(a => a.id === id)) - ); - } - - // ── Persistencia ──────────────────────────────────────────────────────── - - /** Devuelve el Cat en estado actual. Útil para guardar. */ - function getCat(): Cat { - return cat; - } - - /** Marca el store como limpio (p.ej. tras guardar). */ - function markClean() { - dirty = false; - } - - /** Descarta todos los cambios y vuelve al Cat inicial. */ - function discard() { - cat = JSON.parse(JSON.stringify(initial)); - dirty = false; - selection = { kind: 'cat-info' }; - } - - // ── Opt activa (para OptsPanel) ────────────────────────────────────────── - - let activeOptId = $state(null); - - // ── API pública ────────────────────────────────────────────────────────── - - return { - // Estado reactivo - get cat() { return cat; }, - get selection() { return selection; }, - get dirty() { return dirty; }, - get rev() { return rev; }, - get objStates() { return objStates; }, - get secStates() { return secStates; }, - get activeOptId() { return activeOptId; }, - get activeOpt() { return activeOptId ? cat.opts.find(o => o.id === activeOptId) ?? null : null; }, - setActiveOpt(id: string) { activeOptId = id; }, - - // Selección - select, - - // Cat - renameCat, - - // Obj - addObj, renameObj, removeObj, moveObj, - setObjRuleSet, - - // Sec - addSec, renameSec, removeSec, moveSec, - setSecRuleSet, - - // Att (obj/sec level) - addAtt, addCatAtt, renameAtt, removeAtt, moveAtt, - updateAttDomain, updateAttDefaultValue, setAttRuleSet, - - // Att (cat level) - renameCatAtt, removeCatAtt, - updateCatAttDomain, updateCatAttDefaultValue, setCatAttRuleSet, - - // Opts globales - addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt, - addCatAttOpt, removeCatAttOpt, - updateAttOptLabel, updateCatAttOptLabel, - - // OptCategories - addOptCategory, renameOptCategory, removeOptCategory, - - // RuleSet helpers (operan sobre cualquier RuleSet pasado por ref) - addRule, updateRule, removeRule, - addAction, updateAction, removeAction, - - // Slug validators - isObjSlugTaken, isSecSlugTaken, isAttSlugTaken, - - // Persistencia - getCat, markClean, discard, - - // Acceso a finders para los paneles - findObj, findSec, findAtt, findCatAtt, - }; -} - -export type CatalogEditorStore = ReturnType; - -// ───────────────────────────────────────────────────────────────────────────── -// HELPERS -// ───────────────────────────────────────────────────────────────────────────── - -function defaultValueForDomain(domain: AttDomain): unknown { - switch (domain.kind) { - case 'string': return ''; - case 'number': return 0; - case 'currency': return 0; - case 'boolean': return false; - case 'option': return null; - case 'date': return ''; - case 'time': return ''; - case 'datetime': return ''; - } -} diff --git a/src/vcen-edit/demos/demo.store.svelte.ts b/src/vcen-edit/demos/demo.store.svelte.ts index 89a0eae..3f1e6f8 100644 --- a/src/vcen-edit/demos/demo.store.svelte.ts +++ b/src/vcen-edit/demos/demo.store.svelte.ts @@ -238,6 +238,8 @@ function makeFreshCat(): Cat { optCategories: [], id: 'ct_demo' as Cat['id'], name: 'Catálogo demo', + lang: 'es', + langs: ['es'], atts: [], objs: [{ id: 'ob_demo' as Obj['id'], diff --git a/src/vcen-edit/lib/condition-editor.ts b/src/vcen-edit/lib/condition-editor.ts index 7a30401..7b8f1b1 100644 --- a/src/vcen-edit/lib/condition-editor.ts +++ b/src/vcen-edit/lib/condition-editor.ts @@ -72,8 +72,9 @@ export interface ScopeEntry { * @param catOpts - Opts globales del catálogo (Cat.opts) * @param catAtts - Atts de nivel catálogo (Cat.atts) */ -function nameStr(name: unknown, fallback: string): string { - return typeof name === 'string' ? (name || fallback) : fallback; +function nameStr(label: { es?: string; [k: string]: string | undefined } | unknown, fallback: string): string { + if (label && typeof label === 'object' && 'es' in label) return (label as any).es || fallback; + return typeof label === 'string' ? (label || fallback) : fallback; } export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: Att[] = []): ScopeEntry[] { @@ -83,7 +84,7 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: A for (const att of catAtts) { entries.push({ path: `cat/${att.id}`, - label: `Catálogo / ${nameStr(att.name, att.id)}`, + label: `Catálogo / ${nameStr(att.label, att.id)}`, att, domain: att.value.domain, resolvedOpts: resolveOpts(att.value.domain, catOpts), @@ -94,7 +95,7 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: A for (const att of atts) { entries.push({ path: att.id, - label: nameStr(att.name, att.id), + label: nameStr(att.label, att.id), att, domain: att.value.domain, resolvedOpts: resolveOpts(att.value.domain, catOpts), @@ -103,11 +104,11 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: A if ('secs' in container) { for (const sec of container.secs) { - const secLabel = nameStr(sec.name, sec.id); + const secLabel = nameStr(sec.label, sec.id); for (const att of sec.atts) { entries.push({ path: `${sec.id}/${att.id}`, - label: `${secLabel} / ${nameStr(att.name, att.id)}`, + label: `${secLabel} / ${nameStr(att.label, att.id)}`, att, domain: att.value.domain, resolvedOpts: resolveOpts(att.value.domain, catOpts), diff --git a/src/vcen-edit/lib/editor-glob.svelte.ts b/src/vcen-edit/lib/editor-glob.svelte.ts new file mode 100644 index 0000000..137ecb7 --- /dev/null +++ b/src/vcen-edit/lib/editor-glob.svelte.ts @@ -0,0 +1,21 @@ +import { createGlobState } from '@/glob/adapters/svelte' +import { createLing } from '@/ling/lib' +import { EDITOR_TRANSLATIONS } from './editor-translations.ts' +import type { GlobLocale } from '@/glob/lib' + +/** + * Crea una instancia reactiva de glob para el editor de catálogos. + * + * @param locales Lista de locales que soporta el catálogo (para campos multilingüe). + * Por defecto `['es']`. + */ +export function createEditorGlob(locales: GlobLocale[] = ['es']) { + const ling = createLing(EDITOR_TRANSLATIONS, 'es') + return createGlobState({ + ling, + locale : 'es', + locales, + }) +} + +export type EditorGlob = ReturnType diff --git a/src/vcen-edit/lib/editor-translations.ts b/src/vcen-edit/lib/editor-translations.ts new file mode 100644 index 0000000..c59fbb6 --- /dev/null +++ b/src/vcen-edit/lib/editor-translations.ts @@ -0,0 +1,105 @@ +import type { LingNode } from '@/ling/lib' + +/** + * Traducciones del editor de catálogos (vcen-edit). + * Estructuradas como árbol anidado compatible con createLing(). + */ +export const EDITOR_TRANSLATIONS = { + vcen: { + editor: { + mtas: { + header: { + title: { es: 'Metadatos' }, + addField: { es: 'Añadir campo' }, + addFieldTitle:{ es: 'Añadir campo' }, + fieldCount: (p: { count: number }) => ({ es: `${p.count} campo${p.count !== 1 ? 's' : ''}` }), + }, + field: { + empty: { es: 'Sin campos' }, + emptyGroup: { es: 'Grupo vacío' }, + fieldValid: { es: 'Válido' }, + fieldInvalid: { es: 'Inválido' }, + hidden: { es: 'Oculto' }, + edit: { es: 'Editar' }, + delete: { es: 'Eliminar' }, + deleteConfirm: { es: '¿Eliminar campo?' }, + deleteConfirmMsg: { es: 'Esta acción no se puede deshacer.' }, + deleteYes: { es: 'Eliminar' }, + deleteNo: { es: 'Cancelar' }, + moveUp: { es: 'Subir' }, + moveDown: { es: 'Bajar' }, + boolTrue: { es: 'Sí' }, + boolFalse: { es: 'No' }, + nestedCount: (p: { count: number }) => ({ es: `${p.count} campo${p.count !== 1 ? 's' : ''}` }), + }, + tree: { + selectHint: { es: 'Selecciona un campo para editarlo' }, + }, + drawer: { + title: { es: 'Nuevo campo' }, + confirm: { es: 'Añadir' }, + cancel: { es: 'Cancelar' }, + }, + types: { + string: { es: 'Texto' }, + number: { es: 'Número' }, + date: { es: 'Fecha' }, + datetime: { es: 'Fecha y hora' }, + email: { es: 'Email' }, + url: { es: 'URL' }, + slug: { es: 'Slug' }, + }, + editor: { + keyLabel: { es: 'Clave' }, + keyPlaceholder: { es: 'nombre_clave' }, + keyEmpty: { es: 'La clave no puede estar vacía' }, + keyExists: { es: 'Esta clave ya existe' }, + hideField: { es: 'Ocultar campo' }, + attributes: { es: 'Atributos' }, + valueHtml: { es: 'HTML' }, + valueLing: { es: 'Texto multilingual' }, + markdownLabel: { es: 'Markdown' }, + amountLabel: { es: 'Importe' }, + currencyLabel: { es: 'Moneda' }, + imgWidth: { es: 'Ancho' }, + imgHeight: { es: 'Alto' }, + imgFormat: { es: 'Formato' }, + mediaUrlLabel: { es: 'URL del medio' }, + vidDuration: { es: 'Duración' }, + vidPoster: { es: 'Miniatura' }, + audDuration: { es: 'Duración' }, + audBitrate: { es: 'Bitrate' }, + audChannels: { es: 'Canales' }, + coordLat: { es: 'Latitud' }, + coordLng: { es: 'Longitud' }, + coordZoom: { es: 'Zoom' }, + phoneCode: { es: 'Código' }, + phoneNumber: { es: 'Número' }, + colorHex: { es: 'Hex' }, + pdfTitle: { es: 'Título' }, + pdfAuthor: { es: 'Autor' }, + pdfPages: { es: 'Páginas' }, + selectOptions: { es: 'Opciones' }, + selectValue: { es: 'Valor' }, + selectNoOpts: { es: 'Sin opciones' }, + selectAddOpt: { es: '+ Opción' }, + selectOptLabel: { es: 'Etiqueta' }, + selectOptValue: { es: 'Valor' }, + }, + validation: { + valid: { es: 'Válido' }, + errorCount: (p: { count: number }) => ({ es: `${p.count} error${p.count !== 1 ? 'es' : ''}` }), + errorsTitle: { es: 'Errores de validación' }, + }, + pagination: { + all: { es: 'Todos' }, + rowsPerPage:{ es: 'Filas por página' }, + range: (p: { from: number; to: number; total: number }) => ({ es: `${p.from}–${p.to} de ${p.total}` }), + pageN: (p: { page: number }) => ({ es: `Página ${p.page}` }), + prevPage: { es: 'Página anterior' }, + nextPage: { es: 'Página siguiente' }, + }, + }, + }, + }, +} satisfies LingNode diff --git a/src/vcen-edit/lib/label.ts b/src/vcen-edit/lib/label.ts new file mode 100644 index 0000000..2b5a2ae --- /dev/null +++ b/src/vcen-edit/lib/label.ts @@ -0,0 +1,9 @@ +import type { LingRecord } from '@/ling/lib' + +/** + * Resuelve un LingRecord al string del idioma solicitado. + * Fallback: es → fallback string. + */ +export function labelOf(label: LingRecord, lang: string = 'es', fallback = ''): string { + return label[lang as keyof LingRecord] ?? label.es ?? fallback +} diff --git a/src/vcen-edit/stores/catalog-editor.svelte.ts b/src/vcen-edit/stores/catalog-editor.svelte.ts index 527789c..55db851 100644 --- a/src/vcen-edit/stores/catalog-editor.svelte.ts +++ b/src/vcen-edit/stores/catalog-editor.svelte.ts @@ -1,31 +1,41 @@ -import type { Cat, Obj, Sec , Att, AttDomain, AttValue, +import type { Cat, Obj, Sec, Att, AttDomain, AttValue, Opt, OptCategory, AttOpt, - OptID, OptCatID , RuleSet, Rule, RuleAction, + OptID, OptCatID, RuleSet, Rule, RuleAction, RuleID, AttState, RelativePath, DomainValueRef, DomainValueState, - RuleLevel , CatID, ObjID, SecID, AttID } from '@/vcen/lib'; + RuleLevel, CatID, ObjID, SecID, AttID } from '@/vcen/lib'; +import type { LingRecord } from '@/ling/lib'; +import { toAttID, toObjID, toOptID, toRuleID, toSecID } from '@/vcen/lib/node_util'; +import { catOptIDPrefix } from '@/vcen/lib/types/opt'; +import { buildEvalCtx, evalRuleSetState } from '@/vcen-edit/lib/rule-eval.ts'; // ───────────────────────────────────────────────────────────────────────────── // SELECTION — nodo activo en el árbol // ───────────────────────────────────────────────────────────────────────────── -export type SelectionKind = 'cat' | 'obj' | 'sec' | 'att' | 'opts'; +export type SelectionKind = + | 'cat-info' | 'cat-atts' | 'cat-meta' | 'opts' + | 'obj-info' | 'obj-atts' | 'obj-meta' + | 'sec-info' | 'sec-atts' | 'sec-meta'; export type Selection = - | { kind: 'cat' } - | { kind: 'opts' } // panel Opts/OptCategories - | { kind: 'obj'; objId: ObjID } - | { kind: 'sec'; objId: ObjID; secId: SecID } - | { kind: 'att'; objId: ObjID; secId?: SecID; attId: AttID }; + | { kind: 'cat-info' } + | { kind: 'cat-atts'; attId?: AttID } + | { kind: 'cat-meta' } + | { kind: 'opts' } + | { kind: 'obj-info'; objId: ObjID } + | { kind: 'obj-atts'; objId: ObjID; attId?: AttID } + | { kind: 'obj-meta'; objId: ObjID } + | { kind: 'sec-info'; objId: ObjID; secId: SecID } + | { kind: 'sec-atts'; objId: ObjID; secId: SecID; attId?: AttID } + | { kind: 'sec-meta'; objId: ObjID; secId: SecID }; // ───────────────────────────────────────────────────────────────────────────── // ID GENERATORS // ───────────────────────────────────────────────────────────────────────────── let _seq = Date.now(); -function nextId(prefix: string): T { - return `${prefix}:${(++_seq).toString(36)}` as T; -} +function nextSeq(): string { return (++_seq).toString(36); } // ───────────────────────────────────────────────────────────────────────────── // DEFAULT FACTORIES @@ -35,32 +45,35 @@ function defaultAttValue(): AttValue { return { domain: { kind: 'string', mode: 'input' }, value: '' }; } -function defaultAtt(name = 'Nuevo atributo'): Att { - return { id: nextId('at'), name, value: defaultAttValue() }; +function defaultAtt(name = 'Nuevo atributo', slug?: string): Att { + const id = toAttID(slug ?? nextSeq()); + return { id, label: { es: name }, value: defaultAttValue() }; } -function defaultSec(name = 'Nueva sección'): Sec { - return { id: nextId('sc'), name, atts: [] }; +function defaultSec(name = 'Nueva sección', slug?: string): Sec { + const id = toSecID(slug ?? nextSeq()); + return { id, label: { es: name }, atts: [] }; } -function defaultObj(name = 'Nuevo objeto'): Obj { - return { id: nextId('ob'), name, atts: [], secs: [] }; +function defaultObj(name = 'Nuevo objeto', slug?: string): Obj { + const id = toObjID(slug ?? nextSeq()); + return { id, label: { es: name }, atts: [], secs: [] }; } function defaultOpt(value = ''): Opt { return { - id: nextId('op'), + id: toOptID(nextSeq()), value: { kind: 'string', value }, }; } function defaultOptCategory(name = 'Nueva categoría'): OptCategory { - return { id: nextId('oc'), name }; + return { id: `${catOptIDPrefix}_${nextSeq()}` as OptCatID, name }; } function defaultRule(): Rule { return { - id: nextId('rl'), + id: toRuleID(nextSeq()), priority: 10, condition: true, actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }], @@ -90,15 +103,66 @@ function defaultRule(): Rule { * ``` */ export function createCatalogEditorStore(initial: Cat) { - let cat = $state(structuredClone(initial)); - let selection = $state({ kind: 'cat' }); + let cat = $state(JSON.parse(JSON.stringify(initial))); + let selection = $state({ kind: 'cat-info' }); let dirty = $state(false); + let rev = $state(0); // incrementa en cada mutación — fuerza reactividad global + + // Estados calculados por reglas. + // Usamos $effect + $state en lugar de $derived.by para garantizar que + // el efecto se re-ejecuta ante cualquier cambio profundo de `cat` + // (incluyendo att.value.value dentro de buildEvalCtx). + let objStates = $state(new Map()) + let secStates = $state(new Map()) + + /** Valor efectivo de un atributo (mode:fixed → domain.fixed, resto → value.value). */ + function attVal(att: Att): unknown { + const d = att.value.domain as any + return d?.mode === 'fixed' ? (d.fixed ?? att.value.value) : att.value.value + } + + $effect(() => { + const catCtx: Record = {} + for (const att of cat.atts) catCtx[att.id] = attVal(att) + + const m = new Map() + for (const obj of cat.objs) { + if (!obj.rules?.rules?.length) { m.set(obj.id, null); continue } + const ctx: Record = { cat: catCtx } + for (const a of obj.atts) ctx[a.id] = attVal(a) + for (const sec of obj.secs) { + ctx[sec.id] = {} + for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a) + } + m.set(obj.id, evalRuleSetState(obj.rules, ctx)) + } + objStates = m + }) + + $effect(() => { + const catCtx: Record = {} + for (const att of cat.atts) catCtx[att.id] = attVal(att) + + const m = new Map() + for (const obj of cat.objs) { + for (const sec of obj.secs) { + if (!sec.rules?.rules?.length) { m.set(sec.id, null); continue } + const ctx: Record = { cat: catCtx } + for (const a of obj.atts) ctx[a.id] = attVal(a) + ctx[sec.id] = {} + for (const a of sec.atts) ctx[sec.id][a.id] = attVal(a) + m.set(sec.id, evalRuleSetState(sec.rules, ctx)) + } + } + secStates = m + }) // ── Helpers internos ──────────────────────────────────────────────────── function mutate(fn: () => void) { fn(); dirty = true; + rev++; } function findObj(objId: ObjID): Obj | undefined { @@ -130,19 +194,66 @@ export function createCatalogEditorStore(initial: Cat) { mutate(() => { cat.name = name; }); } + function setCatLang(lang: string) { + mutate(() => { + cat.lang = lang; + if (!cat.langs.includes(lang)) cat.langs = [lang, ...cat.langs]; + }); + } + + function setCatLangs(langs: string[]) { + mutate(() => { + // lang principal siempre primero y siempre presente + const next = langs.includes(cat.lang) ? langs : [cat.lang, ...langs]; + cat.langs = next as [string, ...string[]]; + }); + } + + function updateCatMeta(meta: import('@/mtas/lib').Metadata | undefined) { + mutate(() => { (cat as any).metadata = meta }) + } + // ── Obj ───────────────────────────────────────────────────────────────── - function addObj(name?: string): ObjID { - const obj = defaultObj(name); + function addObj(name?: string, slug?: string): ObjID { + const obj = defaultObj(name, slug); mutate(() => { cat.objs.push(obj); }); - select({ kind: 'obj', objId: obj.id }); + select({ kind: 'obj-info', objId: obj.id }); return obj.id; } function renameObj(objId: ObjID, name: string) { mutate(() => { const obj = findObj(objId); - if (obj) obj.name = name; + if (obj) obj.label = { ...obj.label, [cat.lang]: name }; + }); + } + + function setObjLabel(objId: ObjID, label: LingRecord) { + mutate(() => { + const obj = findObj(objId); + if (obj) obj.label = label; + }); + } + + function setSecLabel(objId: ObjID, secId: SecID, label: LingRecord) { + mutate(() => { + const sec = findSec(objId, secId); + if (sec) sec.label = label; + }); + } + + function setAttLabel(objId: ObjID, secId: SecID | undefined, attId: AttID, label: LingRecord) { + mutate(() => { + const att = findAtt(objId, secId, attId); + if (att) att.label = label; + }); + } + + function setCatAttLabel(attId: AttID, label: LingRecord) { + mutate(() => { + const att = findCatAtt(attId); + if (att) att.label = label; }); } @@ -151,8 +262,13 @@ export function createCatalogEditorStore(initial: Cat) { cat.objs = cat.objs.filter(o => o.id !== objId); }); // Limpiar selección si apuntaba al obj eliminado - if (selection.kind === 'obj' && selection.objId === objId) select({ kind: 'cat' }); - if ((selection.kind === 'sec' || selection.kind === 'att') && selection.objId === objId) select({ kind: 'cat' }); + const s = selection; + if ( + (s.kind === 'obj-info' || s.kind === 'obj-atts') && s.objId === objId || + (s.kind === 'sec-info' || s.kind === 'sec-atts') && s.objId === objId + ) { + select({ kind: 'cat-info' }); + } } function moveObj(objId: ObjID, direction: 'up' | 'down') { @@ -165,22 +281,29 @@ export function createCatalogEditorStore(initial: Cat) { }); } + function updateObjMeta(objId: ObjID, meta: import('@/mtas/lib').Metadata | undefined) { + mutate(() => { + const obj = findObj(objId) + if (obj) (obj as any).metadata = meta + }) + } + // ── Sec ───────────────────────────────────────────────────────────────── - function addSec(objId: ObjID, name?: string): SecID { - const sec = defaultSec(name); + function addSec(objId: ObjID, name?: string, slug?: string): SecID { + const sec = defaultSec(name, slug); mutate(() => { const obj = findObj(objId); if (obj) obj.secs.push(sec); }); - select({ kind: 'sec', objId, secId: sec.id }); + select({ kind: 'sec-info', objId, secId: sec.id }); return sec.id; } function renameSec(objId: ObjID, secId: SecID, name: string) { mutate(() => { const sec = findSec(objId, secId); - if (sec) sec.name = name; + if (sec) sec.label = { ...sec.label, [cat.lang]: name }; }); } @@ -189,8 +312,12 @@ export function createCatalogEditorStore(initial: Cat) { const obj = findObj(objId); if (obj) obj.secs = obj.secs.filter(s => s.id !== secId); }); - if (selection.kind === 'sec' && selection.secId === secId) select({ kind: 'obj', objId }); - if (selection.kind === 'att' && selection.secId === secId) select({ kind: 'obj', objId }); + const s = selection; + if ( + (s.kind === 'sec-info' || s.kind === 'sec-atts') && s.secId === secId + ) { + select({ kind: 'obj-atts', objId }); + } } function moveSec(objId: ObjID, secId: SecID, direction: 'up' | 'down') { @@ -212,10 +339,17 @@ export function createCatalogEditorStore(initial: Cat) { }); } + function updateSecMeta(objId: ObjID, secId: SecID, meta: import('@/mtas/lib').Metadata | undefined) { + mutate(() => { + const sec = findSec(objId, secId) + if (sec) (sec as any).metadata = meta + }) + } + // ── Att (en Obj o Sec) ────────────────────────────────────────────────── - function addAtt(objId: ObjID, secId: SecID | undefined, name?: string): AttID { - const att = defaultAtt(name); + function addAtt(objId: ObjID, secId: SecID | undefined, name?: string, slug?: string): AttID { + const att = defaultAtt(name, slug); mutate(() => { if (secId) { const sec = findSec(objId, secId); @@ -225,21 +359,32 @@ export function createCatalogEditorStore(initial: Cat) { if (obj) obj.atts.push(att); } }); - select({ kind: 'att', objId, secId, attId: att.id }); + if (secId) { + select({ kind: 'sec-atts', objId, secId, attId: att.id }); + } else { + select({ kind: 'obj-atts', objId, attId: att.id }); + } return att.id; } - function addCatAtt(name?: string): AttID { - const att = defaultAtt(name); + function addCatAtt(name?: string, slug?: string): AttID { + const att = defaultAtt(name, slug); mutate(() => { cat.atts.push(att); }); - select({ kind: 'att', objId: '' as ObjID, secId: undefined, attId: att.id }); + select({ kind: 'cat-atts', attId: att.id }); return att.id; } function renameAtt(objId: ObjID, secId: SecID | undefined, attId: AttID, name: string) { mutate(() => { const att = findAtt(objId, secId, attId); - if (att) att.name = name; + if (att) att.label = { ...att.label, [cat.lang]: name }; + }); + } + + function renameCatAtt(attId: AttID, name: string) { + mutate(() => { + const att = findCatAtt(attId); + if (att) att.label = { ...att.label, [cat.lang]: name }; }); } @@ -253,8 +398,21 @@ export function createCatalogEditorStore(initial: Cat) { if (obj) obj.atts = obj.atts.filter(a => a.id !== attId); } }); - if (selection.kind === 'att' && selection.attId === attId) { - select(secId ? { kind: 'sec', objId, secId } : { kind: 'obj', objId }); + const s = selection; + if (s.kind === 'obj-atts' && s.attId === attId) { + select({ kind: 'obj-atts', objId }); + } else if (s.kind === 'sec-atts' && s.attId === attId) { + select({ kind: 'sec-atts', objId, secId: secId! }); + } + } + + function removeCatAtt(attId: AttID) { + mutate(() => { + cat.atts = cat.atts.filter(a => a.id !== attId); + }); + const s = selection; + if (s.kind === 'cat-atts' && s.attId === attId) { + select({ kind: 'cat-atts' }); } } @@ -279,6 +437,14 @@ export function createCatalogEditorStore(initial: Cat) { }); } + function updateCatAttDomain(attId: AttID, domain: AttDomain) { + mutate(() => { + const att = findCatAtt(attId); + if (!att) return; + att.value = { domain, value: defaultValueForDomain(domain) } as AttValue; + }); + } + function updateAttDefaultValue(objId: ObjID, secId: SecID | undefined, attId: AttID, value: unknown) { mutate(() => { const att = findAtt(objId, secId, attId); @@ -286,6 +452,27 @@ export function createCatalogEditorStore(initial: Cat) { }); } + function updateCatAttDefaultValue(attId: AttID, value: unknown) { + mutate(() => { + const att = findCatAtt(attId); + if (att) att.value = { ...att.value, value } as typeof att.value; + }); + } + + function updateAttMeta(objId: ObjID, secId: SecID | undefined, attId: AttID, meta: import('@/mtas/lib').Metadata | undefined) { + mutate(() => { + const att = findAtt(objId, secId, attId) + if (att) (att as any).metadata = meta + }) + } + + function updateCatAttMeta(attId: AttID, meta: import('@/mtas/lib').Metadata | undefined) { + mutate(() => { + const att = findCatAtt(attId) + if (att) (att as any).metadata = meta + }) + } + function setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) { mutate(() => { const att = findAtt(objId, secId, attId); @@ -293,6 +480,13 @@ export function createCatalogEditorStore(initial: Cat) { }); } + function setCatAttRuleSet(attId: AttID, ruleSet: RuleSet | undefined) { + mutate(() => { + const att = findCatAtt(attId); + if (att) att.rules = ruleSet; + }); + } + function setObjRuleSet(objId: ObjID, ruleSet: RuleSet | undefined) { mutate(() => { const obj = findObj(objId); @@ -368,6 +562,41 @@ export function createCatalogEditorStore(initial: Cat) { }); } + function addCatAttOpt(attId: AttID, optId: OptID) { + mutate(() => { + const att = findCatAtt(attId) + if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return + const list = (att.value.domain as any).list as AttOpt[] + if (!list.find((ao: AttOpt) => ao.optId === optId)) list.push({ optId }) + }) + } + + function removeCatAttOpt(attId: AttID, optId: OptID) { + mutate(() => { + const att = findCatAtt(attId) + if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return + ;(att.value.domain as any).list = ((att.value.domain as any).list as AttOpt[]).filter((ao: AttOpt) => ao.optId !== optId) + }) + } + + function updateAttOptLabel(objId: ObjID, secId: SecID | undefined, attId: AttID, optId: OptID, label: string) { + mutate(() => { + const att = findAtt(objId, secId, attId) + if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return + const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId) + if (attOpt) attOpt.label = label || undefined + }) + } + + function updateCatAttOptLabel(attId: AttID, optId: OptID, label: string) { + mutate(() => { + const att = findCatAtt(attId) + if (!att || att.value.domain.kind !== 'option' || (att.value.domain as any).mode !== 'list') return + const attOpt = ((att.value.domain as any).list as AttOpt[]).find((ao: AttOpt) => ao.optId === optId) + if (attOpt) attOpt.label = label || undefined + }) + } + // ── OptCategories ─────────────────────────────────────────────────────── function addOptCategory(name?: string): OptCatID { @@ -438,6 +667,25 @@ export function createCatalogEditorStore(initial: Cat) { }); } + // ── Slug validators ───────────────────────────────────────────────────── + + function isObjSlugTaken(slug: string): boolean { + return cat.objs.some(o => o.id === toObjID(slug)); + } + + function isSecSlugTaken(slug: string): boolean { + return cat.objs.some(o => o.secs.some(s => s.id === toSecID(slug))); + } + + function isAttSlugTaken(slug: string): boolean { + const id = toAttID(slug); + if (cat.atts.some(a => a.id === id)) return true; + return cat.objs.some(o => + o.atts.some(a => a.id === id) || + o.secs.some(s => s.atts.some(a => a.id === id)) + ); + } + // ── Persistencia ──────────────────────────────────────────────────────── /** Devuelve el Cat en estado actual. Útil para guardar. */ @@ -452,12 +700,12 @@ export function createCatalogEditorStore(initial: Cat) { /** Descarta todos los cambios y vuelve al Cat inicial. */ function discard() { - cat = structuredClone(initial); + cat = JSON.parse(JSON.stringify(initial)); dirty = false; - selection = { kind: 'cat' }; + selection = { kind: 'cat-info' }; } - // ── Opt activa (para OptsPanel / OptDetail) ────────────────────────────── + // ── Opt activa (para OptsPanel) ────────────────────────────────────────── let activeOptId = $state(null); @@ -465,33 +713,44 @@ export function createCatalogEditorStore(initial: Cat) { return { // Estado reactivo - get cat() { return cat; }, - get selection() { return selection; }, - get dirty() { return dirty; }, - get activeOptId() { return activeOptId; }, - get activeOpt() { return activeOptId ? cat.opts.find(o => o.id === activeOptId) ?? null : null; }, + get cat() { return cat; }, + get selection() { return selection; }, + get dirty() { return dirty; }, + get rev() { return rev; }, + get objStates() { return objStates; }, + get secStates() { return secStates; }, + get activeOptId() { return activeOptId; }, + get activeOpt() { return activeOptId ? cat.opts.find(o => o.id === activeOptId) ?? null : null; }, setActiveOpt(id: string) { activeOptId = id; }, // Selección select, // Cat - renameCat, + renameCat, setCatLang, setCatLangs, updateCatMeta, // Obj - addObj, renameObj, removeObj, moveObj, - setObjRuleSet, + addObj, renameObj, setObjLabel, removeObj, moveObj, + setObjRuleSet, updateObjMeta, // Sec - addSec, renameSec, removeSec, moveSec, - setSecRuleSet, + addSec, renameSec, setSecLabel, removeSec, moveSec, + setSecRuleSet, updateSecMeta, - // Att - addAtt, addCatAtt, renameAtt, removeAtt, moveAtt, + // Att (obj/sec level) + addAtt, addCatAtt, renameAtt, setAttLabel, removeAtt, moveAtt, updateAttDomain, updateAttDefaultValue, setAttRuleSet, + updateAttMeta, + + // Att (cat level) + renameCatAtt, setCatAttLabel, removeCatAtt, + updateCatAttDomain, updateCatAttDefaultValue, setCatAttRuleSet, + updateCatAttMeta, // Opts globales addOpt, updateOpt, removeOpt, addAttOpt, removeAttOpt, + addCatAttOpt, removeCatAttOpt, + updateAttOptLabel, updateCatAttOptLabel, // OptCategories addOptCategory, renameOptCategory, removeOptCategory, @@ -500,6 +759,9 @@ export function createCatalogEditorStore(initial: Cat) { addRule, updateRule, removeRule, addAction, updateAction, removeAction, + // Slug validators + isObjSlugTaken, isSecSlugTaken, isAttSlugTaken, + // Persistencia getCat, markClean, discard, diff --git a/src/vcen-edit/tests/rule-eval.test.ts b/src/vcen-edit/tests/rule-eval.test.ts index 64efbda..1e54f61 100644 --- a/src/vcen-edit/tests/rule-eval.test.ts +++ b/src/vcen-edit/tests/rule-eval.test.ts @@ -14,7 +14,7 @@ function objAtt(slug: string, value: unknown) { } function emptyCat(atts: ReturnType[] = []): Cat { - return { id: toObjID('test') as any, name: 'Test', atts, objs: [], opts: [], optCategories: [] } + return { id: toObjID('test') as any, name: 'Test', lang: 'es', langs: ['es'], atts, objs: [], opts: [], optCategories: [] } } function emptyObj(atts: ReturnType[] = [], secs: any[] = []): Obj { diff --git a/src/vcen/lib/types/att.ts b/src/vcen/lib/types/att.ts index d609110..da1b844 100644 --- a/src/vcen/lib/types/att.ts +++ b/src/vcen/lib/types/att.ts @@ -1,8 +1,8 @@ import type {ID, ISODate, ISODatetime, ISOTime} from "@/tsat/lib/types"; import type {RuleSet} from "@/vcen/lib/types/rule.ts"; -import type {LingString} from "@/ling/lib"; -import type {Metadata, MetadataOverride} from "@/vcen/lib/types/metadata.ts"; +import type {LingRecord} from "@/ling/lib"; +import type { MetadataOverride} from "@/vcen/lib/types/metadata.ts"; import type {CatID} from "@/vcen/lib/types/cat.ts"; import type {ObjID} from "@/vcen/lib/types/obj.ts"; import type {SecID} from "@/vcen/lib/types/sec.ts"; @@ -10,6 +10,7 @@ import type {OptID} from "@/vcen/lib/types/opt.ts"; import type {Pricing} from "@/vcen/lib/types/pricing.ts"; import type {Warn} from "@/vcen/lib/types/warn.ts"; import type {JsonLogicValue} from "@/jslg/lib"; +import type {Metadata} from "@/mtas/lib"; // ───────────────────────────────────────────────────────────────────────────── @@ -364,8 +365,8 @@ export interface AttDisplay { export interface Att { id: AttID; - /** Nombre localizado del atributo */ - name: LingString; + /** Etiqueta localizada del atributo */ + label: LingRecord; /** Valor actual del atributo y definición de su dominio */ value: AttValue; diff --git a/src/vcen/lib/types/cat.ts b/src/vcen/lib/types/cat.ts index 87f4e6e..e0083f3 100644 --- a/src/vcen/lib/types/cat.ts +++ b/src/vcen/lib/types/cat.ts @@ -3,7 +3,8 @@ // ───────────────────────────────────────────────────────────────────────────── import type {ID} from "@/tsat/lib"; -import type {Att,Obj,Opt, OptCategory,Metadata, PricingDefaults, VisualDefaults, Warn} from "./index.ts"; +import type {Att,Obj,Opt, OptCategory,PricingDefaults, VisualDefaults, Warn} from "./index.ts"; +import type {Metadata} from "@/mtas/lib"; @@ -49,6 +50,19 @@ export interface Cat { id: CatID; name: string; + /** + * Idioma principal del catálogo (BCP 47, ej: 'es', 'en', 'fr'). + * Es siempre el primer elemento de `langs`. + */ + lang: string; + + /** + * Lista de idiomas que soporta el catálogo (BCP 47). + * Debe incluir siempre `lang` como mínimo. + * Usada para campos multilingüe en atributos, opciones y metadatos. + */ + langs: [string, ...string[]]; + /** * Valores por defecto de pricing para todo el catálogo. * Todos los nodos heredan currency, vat y vatIncluded de aquí diff --git a/src/vcen/lib/types/obj.ts b/src/vcen/lib/types/obj.ts index 6c72f75..51111e0 100644 --- a/src/vcen/lib/types/obj.ts +++ b/src/vcen/lib/types/obj.ts @@ -6,7 +6,9 @@ import type {ID} from "@/tsat/lib"; import type {Att} from "@/vcen/lib/types/att.ts"; import type {Sec} from "@/vcen/lib/types/sec.ts"; import type {RuleSet} from "@/vcen/lib/types/rule.ts"; -import type {Metadata, Pricing, Visual, Warn} from "@/vcen/lib"; +import type {Pricing, Visual, Warn} from "@/vcen/lib"; +import type {LingRecord} from "@/ling/lib"; +import type {Metadata} from "@/mtas/lib"; @@ -53,7 +55,7 @@ export interface Obj { id: ObjID; /** Nombre localizado del objeto */ - name: string; + label: LingRecord; /** Atributos directos del objeto (fuera de cualquier sección) */ atts: Att[]; diff --git a/src/vcen/lib/types/opt.ts b/src/vcen/lib/types/opt.ts index 87123b4..cf2f60b 100644 --- a/src/vcen/lib/types/opt.ts +++ b/src/vcen/lib/types/opt.ts @@ -1,5 +1,5 @@ -import type {Metadata} from "@/vcen/lib/types/metadata.ts"; -import type {LingString} from "@/ling/lib"; +import type {Metadata} from "@/mtas/lib"; +import type {LingRecord } from "@/ling/lib"; import type { RuleSet} from "@/vcen/lib/types/rule.ts"; import type {ID} from "@/tsat/lib"; import type {Warn} from "@/vcen/lib/types/warn.ts"; @@ -84,7 +84,7 @@ export interface Opt { value: OptValue; /** Etiqueta localizada para mostrar al usuario */ - label?: LingString; + label?: LingRecord; /** valor para usarse en las configuraciones visuales */ diff --git a/src/vcen/lib/types/sec.ts b/src/vcen/lib/types/sec.ts index 3ae2ee8..4bc8467 100644 --- a/src/vcen/lib/types/sec.ts +++ b/src/vcen/lib/types/sec.ts @@ -4,7 +4,9 @@ import type {Att} from "@/vcen/lib/types/att.ts"; import type {RuleSet} from "@/vcen/lib/types/rule.ts"; import type {ID} from "@/tsat/lib"; -import type {Metadata, Pricing, Visual, Warn} from "@/vcen/lib"; +import type { Pricing, Visual, Warn} from "@/vcen/lib"; +import type {LingRecord} from "@/ling/lib"; +import type {Metadata} from "@/mtas/lib"; @@ -47,8 +49,8 @@ export type SecID = ID; export interface Sec { id: SecID; - /** Nombre localizado de la sección */ - name: string; + /** Etiqueta localizada de la sección */ + label: LingRecord; /** Atributos directos de esta sección */ atts: Att[]; diff --git a/src/vcen/lib/types/view.ts b/src/vcen/lib/types/view.ts index 7492a5e..8ca21f2 100644 --- a/src/vcen/lib/types/view.ts +++ b/src/vcen/lib/types/view.ts @@ -1,8 +1,9 @@ // view.ts import type {ID} from "@/tsat/lib"; -import type {AttID, Metadata} from "@/vcen/lib"; -import type {LingString} from "@/ling/lib"; -import type {JsonLogicExpression, JsonLogicValue} from "@/jslg/lib"; +import type {AttID } from "@/vcen/lib"; +import type {LingRecord } from "@/ling/lib"; +import type { JsonLogicValue} from "@/jslg/lib"; +import type {Metadata} from "@/mtas/lib"; // ───────────────────────────────────────────────────────────────────────────── // IDENTITY @@ -294,13 +295,13 @@ export type HotspotMode = 'tooltip' | 'popup'; */ export interface Hotspot { id: HotspotID; - name: LingString; + label: LingRecord; mode : HotspotMode; /** Att que se activa/focaliza al interactuar con el hotspot */ targetAtt?: AttID; /** Si false o condición que evalúa false, el hotspot no se muestra */ visible?: boolean | JsonLogicValue; - tooltip?: LingString; + tooltip?: LingRecord; metadata?: Metadata; /** Posición X — absoluta (px) o relativa ('22%') */ posX: number | string; @@ -324,8 +325,8 @@ export interface Hotspot { * El usuario puede cambiar entre ellas en la UI. */ export interface View { - id: ViewID; - name: LingString; + id : ViewID; + label: LingRecord; /** * Código corto de la vista para referenciarla desde reglas u otras vistas. * @example 'front' | 'detail-wheel' | 'interior'