diff --git a/mtas.html b/mtas.html new file mode 100644 index 0000000..99241df --- /dev/null +++ b/mtas.html @@ -0,0 +1,14 @@ + + + + + + VICE — Catalog Editor + + +
+ + + + + \ No newline at end of file diff --git a/src/vcen-edit/App.svelte b/src/vcen-edit/App.svelte index 45e4dde..b6946f4 100644 --- a/src/vcen-edit/App.svelte +++ b/src/vcen-edit/App.svelte @@ -1,116 +1,25 @@ -
- - - -
- - -
-

- LingInput Demo -

-

- Svelte 5 + Tailwind CSS 4 · - LingRecord -

-
- - -
-

- 1 · Input · 2 locales · required -

- titulo = v} - - /> -
{JSON.stringify(titulo, null, 2)}
-
- - -
-

- 2 · Textarea · 4 locales -

- descripcion = v} - /> -
{JSON.stringify(descripcion, null, 2)}
-
- - -
-

- 3 · Snippet custom · Contenido inicial -

- - {#snippet localeTab({ locale, active, hasContent, activate })} - - {/snippet} - -
{JSON.stringify(nombre, null, 2)}
-
- -
-
\ No newline at end of file +
+ +
diff --git a/src/vcen-edit/canvas/Editor.svelte b/src/vcen-edit/canvas/Editor.svelte index 960a737..c1ca4d5 100644 --- a/src/vcen-edit/canvas/Editor.svelte +++ b/src/vcen-edit/canvas/Editor.svelte @@ -4,26 +4,39 @@ import CatalogEditor from "@/vcen-edit/comps/CatalogEditor.svelte"; - const cat = { - id: toCatID('test'), - name: 'Nuevo', - optCategories: [], - opts: [], - atts: [], - objs: [], + const STORAGE_KEY = 'vcen-editor-cat' + + function loadCat(): Cat { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) return JSON.parse(stored) + } catch {} + return { + id: toCatID('test'), + name: 'Nuevo', + optCategories: [], + opts: [], + atts: [], + objs: [], + } } + function saveCat(cat: Cat) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cat)) + } catch {} + } - + const cat = loadCat()
- +
\ No newline at end of file + diff --git a/src/vcen-edit/comps/AttDetail.svelte b/src/vcen-edit/comps/AttDetail.svelte index 31e174c..dd040d1 100644 --- a/src/vcen-edit/comps/AttDetail.svelte +++ b/src/vcen-edit/comps/AttDetail.svelte @@ -1,29 +1,50 @@ {#if ctx} - {@const { att, objId, secId, attId, container } = ctx} + {@const { att, isCatLevel, objId, secId, attId, container } = ctx}
@@ -88,7 +160,16 @@ function clearRules() {

{att.name || 'Sin nombre'}

- {att.id} +
+ {#if computedState} + + {stateLabel[computedState] ?? computedState} + + {:else if att.rules?.rules?.length} + → Disponible + {/if} + {att.id} +
@@ -127,8 +208,7 @@ function clearRules() {
{#each ['input', 'list', 'pattern', 'fixed'] as mode}
+ {:else if d.mode === 'list'} + {:else if d.mode === 'fixed'} editor.updateAttDomain(objId, secId, attId, + oninput={e => updateDomain( { kind: att.value.domain.kind, mode: 'fixed', fixed: Number(e.currentTarget.value) } as any)} class="field-input" /> {/if} {:else if att.value.domain.kind === 'option'} + {@const d = att.value.domain as any}
-
- +
+ {#each [['list', 'Lista'], ['fixed', 'Fijo']] as [mode, modeLabel]} + + {/each}
- {#if allOpts.length === 0} -

- No hay opciones globales — créalas en el panel Opciones -

- {:else} -
- {#each allOpts as opt (opt.id)} - {@const checked = attOptIds.has(opt.id)} - - {/each} + + {#if d.mode === 'list'} +
+ +
+ {#if allOpts.length === 0} +

+ No hay opciones globales — créalas en el panel Opciones +

+ {:else} +
+ {#each allOpts as opt (opt.id)} + {@const checked = attOptIds.has(opt.id)} + {@const attOpt = checked ? (d.list ?? []).find((ao: any) => ao.optId === opt.id) : null} +
+ + {#if checked} + updateOptLabel(opt.id, e.currentTarget.value)} + placeholder="Etiqueta personalizada..." + class="mt-1.5 ml-7 field-input text-xs" /> + {/if} +
+ {/each} +
+ {/if} + + {:else if d.mode === 'fixed'} +
+ + {#if allOpts.length === 0} +

No hay opciones globales

+ {:else} + + {/if}
{/if}
@@ -264,7 +399,7 @@ function clearRules() { {/each}
- {:else if att.value.domain.kind === 'option'} + {:else if att.value.domain.kind === 'option' && (att.value.domain as any).mode === 'list'} {@const d = att.value.domain as any} { if (!newAttSlug || newAttSlug === slugify(newAttName.slice(0,-1))) newAttSlug = slugify(e.currentTarget.value) }} + placeholder="Nombre del atributo" + class="field-input text-xs" + autofocus + /> +
+ 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 attId} + + {:else} +
+ Selecciona un atributo para editarlo +
+ {/if} +
+ diff --git a/src/vcen-edit/comps/CatalogLayout.svelte b/src/vcen-edit/comps/CatalogLayout.svelte index 40b0456..36e01cc 100644 --- a/src/vcen-edit/comps/CatalogLayout.svelte +++ b/src/vcen-edit/comps/CatalogLayout.svelte @@ -1,66 +1,203 @@ -
- - - - - -
- - -
-
- CAT - | - {editor.cat.name || 'Sin nombre'} - {#if editor.dirty} - ● Sin guardar - {/if} -
-
- {#if oncancel} - - {/if} +
+ + +
+ +
+ VCEN + / + {editor.cat.name || 'Sin nombre'} + {#if editor.dirty} + ● modificado + {/if} +
+ + +
+ + + + +
+ + {#if oncancel} + + {/if} + {#if editor.dirty} + -
-
- - -
- {#if panel === 'cat'} - {:else if panel === 'objs'} - {:else if panel === 'opts'} {/if}
+
+ + +
+ + +
+ +
+ + +
+ {#if sel.kind === 'cat-info'} + {:else if sel.kind === 'cat-atts'} + {:else if sel.kind === 'opts'} + {:else if sel.kind === 'obj-info'} + {:else if sel.kind === 'obj-atts'} + {:else if sel.kind === 'sec-info'} + {:else if sel.kind === 'sec-atts'} + {/if} + + + {#if showJson} +
+
+ JSON del catálogo + +
+
{JSON.stringify(editor.cat, null, 2)}
+
+ {/if} + + + {#if showGraph} +
+
+ Grafo de dependencias + +
+ + {#if graphResult.error} +
+ {graphResult.error} +
+ + {:else if graphResult.graph} + {@const g = graphResult.graph} + {@const deps = [...g.attDeps.entries()].filter(([, set]) => set.size > 0)} + + {#if deps.length === 0} +

Sin dependencias entre atributos.

+ {:else} +
+ {#each deps as [key, depSet]} +
+
+ + {resolveNodeLabel(editor.cat, key)} + + depende de +
+ {#each [...depSet] as dep} + + {resolveNodeLabel(editor.cat, dep)} + + {/each} +
+
+
+ {/each} +
+ +
+

Resumen

+

+ {g.attDeps.size} nodos · + {[...g.attDeps.values()].reduce((s, set) => s + set.size, 0)} dependencias · + {g.ruleMeta.size} reglas +

+
+ {/if} + {/if} +
+ {/if} +
+
diff --git a/src/vcen-edit/comps/CatalogSidebar.svelte b/src/vcen-edit/comps/CatalogSidebar.svelte index bc591a6..3edb534 100644 --- a/src/vcen-edit/comps/CatalogSidebar.svelte +++ b/src/vcen-edit/comps/CatalogSidebar.svelte @@ -1,33 +1,350 @@ - + onclick={() => { creatingObj = true; newObjName = ''; newObjSlug = ''; objSlugError = '' }} + class="text-gray-700 hover:text-indigo-400 transition-colors text-sm leading-none" + title="Nuevo objeto" + >+ +
+ + {#if creatingObj} +
+ { if (!newObjSlug || newObjSlug === slugify(newObjName.slice(0,-1))) newObjSlug = slugify(e.currentTarget.value) }} + placeholder="Nombre del objeto" + class="field-input text-xs" + autofocus + /> +
+ ob: + { objSlugError = '' }} + onkeydown={e => { if (e.key === 'Enter') confirmAddObj(); if (e.key === 'Escape') cancelAddObj() }} + placeholder="slug-del-objeto" + class="field-input text-xs font-mono flex-1" + /> +
+ {#if objSlugError} +

{objSlugError}

+ {/if} +
+ + +
+
+ {/if} + + {#if cat.objs.length === 0} +
Sin objetos
+ {:else} + {#each cat.objs as obj (obj.id)} + {@const isObjOpen = openObjs.has(obj.id)} + {@const objAttCount = obj.atts.length} + {@const secCount = obj.secs.length} + + +
toggleObj(obj.id)} + onkeydown={e => e.key === 'Enter' && toggleObj(obj.id)} + class="flex items-center gap-1.5 px-3 py-1.5 text-xs cursor-pointer + select-none transition-colors text-gray-400 hover:text-gray-200 hover:bg-gray-800/40 + border-l-2 border-transparent group" + > + + + + + {obj.name || 'Sin nombre'} + +
+ + {#if isObjOpen} + + + + + + +
+ + +
+ + {#if creatingSecFor === obj.id} +
+ { if (!newSecSlug || newSecSlug === slugify(newSecName.slice(0,-1))) newSecSlug = slugify(e.currentTarget.value) }} + placeholder="Nombre de la sección" + class="field-input text-xs" + /> +
+ sc: + { secSlugError = '' }} + onkeydown={e => { if (e.key === 'Enter') confirmAddSec(obj.id); if (e.key === 'Escape') cancelAddSec() }} + placeholder="slug-seccion" + class="field-input text-xs font-mono flex-1" + /> +
+ {#if secSlugError} +

{secSlugError}

+ {/if} +
+ + +
+
+ {/if} + + {#each obj.secs as sec (sec.id)} + {@const isSecOpen = openSecs.has(sec.id)} + {@const secAttCount = sec.atts.length} + + +
toggleSec(sec.id)} + onkeydown={e => e.key === 'Enter' && toggleSec(sec.id)} + class="flex items-center gap-1.5 pl-9 pr-3 py-1 text-xs cursor-pointer + select-none transition-colors text-gray-500 hover:text-gray-200 hover:bg-gray-800/40 + border-l-2 border-transparent group" + > + + {sec.name || 'Sin nombre'} + +
+ + {#if isSecOpen} + + + {/if} + {/each} + {/if} + {/each} + {/if} + diff --git a/src/vcen-edit/comps/ConditionBuilder.svelte b/src/vcen-edit/comps/ConditionBuilder.svelte index d6fafb5..9ed737e 100644 --- a/src/vcen-edit/comps/ConditionBuilder.svelte +++ b/src/vcen-edit/comps/ConditionBuilder.svelte @@ -34,7 +34,7 @@ const isEmpty = $derived( // Scope para los selects de atributo const scopeAttrs = $derived( - store.scope.map(e => ({ id: e.path, label: e.path.replace('/', ' / ') })) + store.scope.map(e => ({ id: e.path, label: e.label })) ) function getLeftPath(node: ConditionNode): string { @@ -233,7 +233,7 @@ const OPS_LABELS: Record = { hover:border-indigo-700 transition-colors" >+ Añadir cláusula - {#if store.tautology.kind !== 'not-tautology'} + {#if store.tautology.kind !== 'unknown'}

⚠ {store.tautology.kind === 'always-true' ? 'Siempre verdadera' : 'Siempre falsa'}

diff --git a/src/vcen-edit/comps/ObjAttsPanel.svelte b/src/vcen-edit/comps/ObjAttsPanel.svelte new file mode 100644 index 0000000..a44deb1 --- /dev/null +++ b/src/vcen-edit/comps/ObjAttsPanel.svelte @@ -0,0 +1,152 @@ + + +{#if ctx?.obj} + {@const { objId, obj, attId } = ctx} +
+ +
+
+ + Atributos ({obj.atts.length}) + + +
+
+ {#if obj.atts.length === 0} +
+ Sin atributos + +
+ {:else} + {#each obj.atts as att (att.id)} + {@const isActive = attId === att.id} +
editor.select({ kind: 'obj-atts', objId, attId: att.id })} + onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'obj-atts', objId, attId: att.id })} + class="px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer + transition-colors group border-l-2 + {isActive + ? 'bg-indigo-600/20 text-indigo-300 border-indigo-500' + : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}" + > + + {att.value.domain.kind.slice(0, 3)} + + + {#if att.name}{att.name}{:else}Sin nombre{/if} + + {#if att.rules?.rules?.length} + + {/if} + +
+ {/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: + { 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 attId} + + {:else} +
+ Selecciona un atributo para editarlo +
+ {/if} +
+
+{/if} diff --git a/src/vcen-edit/comps/ObjInfoPanel.svelte b/src/vcen-edit/comps/ObjInfoPanel.svelte new file mode 100644 index 0000000..4c10f03 --- /dev/null +++ b/src/vcen-edit/comps/ObjInfoPanel.svelte @@ -0,0 +1,100 @@ + + +{#if obj && objId} +
+
+

{obj.name || 'Sin nombre'}

+
+ {#if computedState} + + {stateLabel[computedState] ?? computedState} + + {:else if obj.rules?.rules?.length} + sin coincidencia → Disponible + {/if} + {obj.id} +
+
+ + +
+ {#each [ + { label: 'Atributos directos', value: obj.atts.length }, + { label: 'Secciones', value: obj.secs.length }, + { label: 'Reglas', value: obj.rules?.rules?.length ?? 0 }, + ] as s} +
+ {s.value} + {s.label} +
+ {/each} +
+ + +
+

Identificación

+
+ + editor.renameObj(objId, e.currentTarget.value)} + placeholder="Habitación, Cocina..." + class="field-input" /> +
+
+ + +
+
+ + +
+
+ Reglas ({obj.rules?.rules?.length ?? 0}) + {#if obj.rules} + + {:else} + + {/if} +
+ {#if obj.rules} + + {:else} +

Sin reglas

+ {/if} +
+
+{/if} diff --git a/src/vcen-edit/comps/RuleEditor.svelte b/src/vcen-edit/comps/RuleEditor.svelte index 0c3213f..282db8f 100644 --- a/src/vcen-edit/comps/RuleEditor.svelte +++ b/src/vcen-edit/comps/RuleEditor.svelte @@ -1,18 +1,7 @@
- +
P onchange({ priority: Number(e.currentTarget.value) })} + oninput={e => editor.updateRule(ruleSet, rule.id, { priority: Number(e.currentTarget.value) })} class="w-12 rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-xs text-gray-300 focus:outline-none focus:border-indigo-500 shrink-0" /> - onchange({ label: e.currentTarget.value } as any)} + oninput={e => editor.updateRule(ruleSet, rule.id, { label: e.currentTarget.value } as any)} placeholder="Descripción de la regla..." class="flex-1 bg-transparent border-transparent text-xs text-gray-400 placeholder-gray-700 rounded px-1.5 py-0.5 hover:border-gray-700 focus:border-gray-600 focus:bg-gray-800 border focus:outline-none transition-colors" /> - - - {rule.actions.length} acc - - + {/each}
- {#if rule.actions.length === 0} -

Sin acciones

- {/if} - - {#each rule.actions as action, idx (idx)} -
- - -
- - {ACTION_KINDS.find(k => k.kind === action.type)?.label ?? action.type} - - patchAction(idx, { label: e.currentTarget.value })} - placeholder="Descripción..." - class="flex-1 bg-transparent border-transparent text-xs text-gray-500 - placeholder-gray-700 rounded px-1 py-0.5 hover:border-gray-700 - focus:border-gray-600 focus:bg-gray-800 border - focus:outline-none transition-colors" - /> - -
- - - {#if action.type === 'attribute'} -
-
- Si cumple - -
-
- Si no cumple - -
-
-
- Target (vacío = este atributo) - -
- - {:else if action.type === 'validation'} -
-
- Nivel - -
-
- Mensaje - patchAction(idx, { message: e.currentTarget.value })} - placeholder="Campo obligatorio" - class="field-input" /> -
-
- - {:else if action.type === 'domain'} -
-
- Estado - -
-
- Target - -
-
- - {:else if action.type === 'value'} -
- Valor (JSON) - { - try { - const parsed = JSON.parse(e.currentTarget.value) - patchAction(idx, { value: { ...action.value, value: parsed } }) - } catch { /* esperar JSON válido */ } - }} - placeholder='"texto" o 42 o true' - class="field-input font-mono text-xs" /> -
- {/if} - + + {#if selfAttId !== undefined && containerAttrs.length > 0} +
+ Aplicar a +
- {/each} + {/if}
diff --git a/src/vcen-edit/comps/RuleList.svelte b/src/vcen-edit/comps/RuleList.svelte index 063280a..bbb76f7 100644 --- a/src/vcen-edit/comps/RuleList.svelte +++ b/src/vcen-edit/comps/RuleList.svelte @@ -14,10 +14,12 @@ let { ruleSet, container, catOpts, + selfAttId, }: { - ruleSet: RuleSet - container: Obj | Sec - catOpts: typeof editor.cat.opts + ruleSet: RuleSet + container: Obj | Sec + catOpts: typeof editor.cat.opts + selfAttId?: string } = $props() function addRule() { @@ -36,7 +38,7 @@ function addRule() { {ruleSet} {container} {catOpts} - {editor} + {selfAttId} /> {/each} {/if} diff --git a/src/vcen-edit/comps/SecAttsPanel.svelte b/src/vcen-edit/comps/SecAttsPanel.svelte new file mode 100644 index 0000000..16d631a --- /dev/null +++ b/src/vcen-edit/comps/SecAttsPanel.svelte @@ -0,0 +1,153 @@ + + +{#if ctx?.sec} + {@const { objId, secId, sec, attId } = ctx} +
+
+ +
+ editor.renameSec(objId, secId, e.currentTarget.value)} + placeholder="Nombre sección" class="field-input text-xs" /> +
+
+ + Atributos ({sec.atts.length}) + + +
+
+ {#if sec.atts.length === 0} +
+ Sin atributos + +
+ {:else} + {#each sec.atts as att (att.id)} + {@const isActive = attId === att.id} +
editor.select({ kind: 'sec-atts', objId, secId, attId: att.id })} + onkeydown={e => e.key === 'Enter' && editor.select({ kind: 'sec-atts', objId, secId, attId: att.id })} + class="px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer + transition-colors group border-l-2 + {isActive + ? 'bg-indigo-600/20 text-indigo-300 border-indigo-500' + : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'}" + > + + {att.value.domain.kind.slice(0, 3)} + + + {#if att.name}{att.name}{:else}Sin nombre{/if} + + {#if att.rules?.rules?.length} + + {/if} + +
+ {/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: + { 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 attId} + + {:else} +
+ Selecciona un atributo para editarlo +
+ {/if} +
+
+{/if} diff --git a/src/vcen-edit/comps/SecInfoPanel.svelte b/src/vcen-edit/comps/SecInfoPanel.svelte new file mode 100644 index 0000000..cd3c107 --- /dev/null +++ b/src/vcen-edit/comps/SecInfoPanel.svelte @@ -0,0 +1,71 @@ + + +{#if ctx?.sec} + {@const { objId, secId, sec } = ctx} +
+
+

{sec.name || 'Sin nombre'}

+ {sec.id} +
+ +
+ {#each [ + { label: 'Atributos', value: sec.atts.length }, + { label: 'Reglas', value: sec.rules?.rules?.length ?? 0 }, + ] as s} +
+ {s.value} + {s.label} +
+ {/each} +
+ +
+

Identificación

+
+ + editor.renameSec(objId, secId, e.currentTarget.value)} + placeholder="Suelo, Paredes..." + class="field-input" /> +
+
+ + +
+
+ +
+
+ Reglas ({sec.rules?.rules?.length ?? 0}) + {#if sec.rules} + + {:else} + + {/if} +
+ {#if sec.rules} + + {:else} +

Sin reglas

+ {/if} +
+
+{/if} diff --git a/src/vcen-edit/comps/catalog-editor.svelte.ts b/src/vcen-edit/comps/catalog-editor.svelte.ts index e44e7e0..7f5fb6f 100644 --- a/src/vcen-edit/comps/catalog-editor.svelte.ts +++ b/src/vcen-edit/comps/catalog-editor.svelte.ts @@ -9,14 +9,19 @@ import type { Cat, Obj, Sec, Att, AttDomain, AttValue, // SELECTION — nodo activo en el árbol // ───────────────────────────────────────────────────────────────────────────── -export type SelectionKind = 'cat' | 'obj' | 'sec' | 'att' | 'opts'; +export type SelectionKind = + | 'cat-info' | 'cat-atts' | 'opts' + | 'obj-info' | 'obj-atts' + | 'sec-info' | 'sec-atts'; 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: '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 @@ -35,16 +40,19 @@ 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 = slug ? `at:${slug}` as AttID : nextId('at'); + return { id, 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 = slug ? `sc:${slug}` as SecID : nextId('sc'); + return { id, 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 = slug ? `ob:${slug}` as ObjID : nextId('ob'); + return { id, name, atts: [], secs: [] }; } function defaultOpt(value = ''): Opt { @@ -63,7 +71,7 @@ function defaultRule(): Rule { id: nextId('rl'), priority: 10, condition: true, - actions: [], + actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }], }; } @@ -90,10 +98,9 @@ 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 panel = $state<'cat' | 'objs' | 'opts'>('objs'); // ── Helpers internos ──────────────────────────────────────────────────── @@ -133,10 +140,10 @@ export function createCatalogEditorStore(initial: Cat) { // ── 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; } @@ -152,8 +159,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') { @@ -168,13 +180,13 @@ export function createCatalogEditorStore(initial: Cat) { // ── 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; } @@ -190,8 +202,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') { @@ -215,8 +231,8 @@ export function createCatalogEditorStore(initial: Cat) { // ── 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); @@ -226,14 +242,18 @@ 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; } @@ -244,6 +264,13 @@ export function createCatalogEditorStore(initial: Cat) { }); } + 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) { @@ -254,8 +281,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' }); } } @@ -280,6 +320,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); @@ -287,6 +335,13 @@ 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 setAttRuleSet(objId: ObjID, secId: SecID | undefined, attId: AttID, ruleSet: RuleSet | undefined) { mutate(() => { const att = findAtt(objId, secId, attId); @@ -294,6 +349,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); @@ -369,6 +431,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 { @@ -402,12 +499,13 @@ export function createCatalogEditorStore(initial: Cat) { return rule.id; } - function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial>) { + 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; }); } @@ -438,6 +536,25 @@ export function createCatalogEditorStore(initial: Cat) { }); } + // ── Slug validators ───────────────────────────────────────────────────── + + function isObjSlugTaken(slug: string): boolean { + return cat.objs.some(o => o.id === `ob:${slug}`); + } + + function isSecSlugTaken(slug: string): boolean { + return cat.objs.some(o => o.secs.some(s => s.id === `sc:${slug}`)); + } + + function isAttSlugTaken(slug: string): boolean { + const id = `at:${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,9 +569,9 @@ 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) ────────────────────────────────────────── @@ -486,12 +603,18 @@ export function createCatalogEditorStore(initial: Cat) { addSec, renameSec, removeSec, moveSec, setSecRuleSet, - // Att + // 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, @@ -500,13 +623,12 @@ export function createCatalogEditorStore(initial: Cat) { addRule, updateRule, removeRule, addAction, updateAction, removeAction, + // Slug validators + isObjSlugTaken, isSecSlugTaken, isAttSlugTaken, + // Persistencia getCat, markClean, discard, - // Panel - get activePanel() { return panel; }, - setPanel(p: 'cat' | 'objs' | 'opts') { panel = p; }, - // Acceso a finders para los paneles findObj, findSec, findAtt, findCatAtt, }; diff --git a/src/vcen-edit/lib/condition-editor.ts b/src/vcen-edit/lib/condition-editor.ts index 751f350..7a30401 100644 --- a/src/vcen-edit/lib/condition-editor.ts +++ b/src/vcen-edit/lib/condition-editor.ts @@ -53,6 +53,7 @@ export interface ResolvedOpt { export interface ScopeEntry { path: string; // relativo al contenedor: "at_color", "sc_motor/at_fuel" + label: string; // nombre legible: "País", "Catálogo / País", "Motor / Combustible" att: Att; domain: AttDomain; resolvedOpts?: ResolvedOpt[]; // solo presente si domain.kind === 'option' && mode === 'list' @@ -60,7 +61,8 @@ export interface ScopeEntry { /** * Construye el scope de variables disponibles para una regla. - * El scope incluye todos los atts del contenedor y sus secciones hijas. + * El scope incluye todos los atts del contenedor y sus secciones hijas, + * más los atts de nivel catálogo (prefijados con "cat/"). * * Para atts de dominio `option/list`, resuelve las opciones disponibles * cruzando las `AttOpt` del dominio con los `Opt` globales del catálogo, @@ -68,14 +70,31 @@ export interface ScopeEntry { * * @param container - Obj o Sec que contiene la regla * @param catOpts - Opts globales del catálogo (Cat.opts) + * @param catAtts - Atts de nivel catálogo (Cat.atts) */ -export function buildScope(container: Obj | Sec, catOpts: Opt[] = []): ScopeEntry[] { +function nameStr(name: unknown, fallback: string): string { + return typeof name === 'string' ? (name || fallback) : fallback; +} + +export function buildScope(container: Obj | Sec, catOpts: Opt[] = [], catAtts: Att[] = []): ScopeEntry[] { const entries: ScopeEntry[] = []; + // Atts de nivel catálogo — visibles desde cualquier regla + for (const att of catAtts) { + entries.push({ + path: `cat/${att.id}`, + label: `Catálogo / ${nameStr(att.name, att.id)}`, + att, + domain: att.value.domain, + resolvedOpts: resolveOpts(att.value.domain, catOpts), + }); + } + const atts = 'atts' in container ? container.atts : []; for (const att of atts) { entries.push({ path: att.id, + label: nameStr(att.name, att.id), att, domain: att.value.domain, resolvedOpts: resolveOpts(att.value.domain, catOpts), @@ -84,9 +103,11 @@ export function buildScope(container: Obj | Sec, catOpts: Opt[] = []): ScopeEntr if ('secs' in container) { for (const sec of container.secs) { + const secLabel = nameStr(sec.name, sec.id); for (const att of sec.atts) { entries.push({ path: `${sec.id}/${att.id}`, + label: `${secLabel} / ${nameStr(att.name, att.id)}`, att, domain: att.value.domain, resolvedOpts: resolveOpts(att.value.domain, catOpts), @@ -271,8 +292,15 @@ function newId(): string { return `node_${++_idCounter}`; } * Útil para cargar reglas existentes en el editor. */ export function parseCondition(value: JsonLogicValue): ConditionNode { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - // Literal booleano como condición raíz — envolver en compare + // true/false/null como condición raíz → nodo lógico vacío (true = siempre activa) + if (value === true || value === null) { + return makeLogicNode('and', []); + } + if (value === false) { + return makeLogicNode('or', []); + } + if (typeof value !== 'object' || Array.isArray(value)) { + // Otro literal primitivo — envolver en compare return makeCompareNode('==', { kind: 'literal', value }, { kind: 'literal', value: true }); } @@ -425,9 +453,6 @@ export function validateCondition( function walk(n: ConditionNode) { if (n.kind === 'logic') { - if (n.children.length === 0) { - errors.push({ nodeId: n.id, message: `Operador "${n.op}" sin condiciones hijas` }); - } n.children.forEach(walk); return; } diff --git a/src/vcen-edit/lib/rule-eval.ts b/src/vcen-edit/lib/rule-eval.ts new file mode 100644 index 0000000..9124c02 --- /dev/null +++ b/src/vcen-edit/lib/rule-eval.ts @@ -0,0 +1,71 @@ +/** + * Utilidades para evaluar reglas contra valores por defecto del catálogo. + * Permite mostrar el estado calculado de un objeto/atributo en el editor. + */ +import type { Cat, RuleSet, AttState, Obj, Sec } from '@/vcen/lib' +import { JsonLogicEngine } from '@/jslg/lib' + +const _engine = new JsonLogicEngine() + +/** + * Construye el contexto de datos para evaluar condiciones JsonLogic. + * Usa los valores por defecto de cada atributo como valores actuales. + * + * Rutas accesibles: + * cat/ → atributo de catálogo + * → atributo directo del contenedor + * / → atributo de sección (solo si container es Obj) + */ +export function buildEvalCtx(cat: Cat, container: Obj | Sec): Record { + const ctx: Record = {} + + ctx['cat'] = {} + for (const att of cat.atts ?? []) { + ctx['cat'][att.id] = att.value.value + } + + for (const att of container.atts) { + ctx[att.id] = att.value.value + } + + if ('secs' in container) { + for (const sec of (container as Obj).secs) { + ctx[sec.id] = {} + for (const att of sec.atts) { + ctx[sec.id][att.id] = att.value.value + } + } + } + + return ctx +} + +/** Evalúa un RuleSet y retorna el AttState que gana (primera regla activa), o null. */ +export function evalRuleSetState( + ruleSet: RuleSet | undefined, + ctx: Record, +): AttState | null { + if (!ruleSet?.rules.length) return null + + // Mayor prioridad primero (número mayor = se evalúa antes) + const sorted = [...ruleSet.rules].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) + + for (const rule of sorted) { + let fires: boolean + try { + fires = rule.condition === true + ? true + : Boolean(_engine.evaluate(rule.condition as any, ctx)) + } catch { continue } + + if (!fires) continue + + for (const action of rule.actions) { + if (action.type === 'attribute' && (!action.target || action.target.length === 0)) { + return action.ifTrue + } + } + } + + return null +} diff --git a/src/vcen-edit/stores/catalog-editor.svelte.ts b/src/vcen-edit/stores/catalog-editor.svelte.ts index 79a79eb..527789c 100644 --- a/src/vcen-edit/stores/catalog-editor.svelte.ts +++ b/src/vcen-edit/stores/catalog-editor.svelte.ts @@ -63,7 +63,7 @@ function defaultRule(): Rule { id: nextId('rl'), priority: 10, condition: true, - actions: [], + actions: [{ type: 'attribute', target: [], ifTrue: 'hidden', ifFalse: 'available' }], }; } @@ -401,12 +401,13 @@ export function createCatalogEditorStore(initial: Cat) { return rule.id; } - function updateRule(ruleSet: RuleSet, ruleId: RuleID, patch: Partial>) { + 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; }); } diff --git a/src/vcen-edit/stores/condition-builder.svelte.ts b/src/vcen-edit/stores/condition-builder.svelte.ts index c10343b..1b2b386 100644 --- a/src/vcen-edit/stores/condition-builder.svelte.ts +++ b/src/vcen-edit/stores/condition-builder.svelte.ts @@ -1,5 +1,5 @@ import type { JsonLogicValue } from '@/jslg/lib'; -import type {Obj, Opt, Sec} from '@/vcen/lib/types'; +import type {Att, Obj, Opt, Sec} from '@/vcen/lib/types'; import type { ScopeEntry, ConditionNode, @@ -54,6 +54,7 @@ export function createConditionBuilderStore( container: Obj | Sec, initial?: JsonLogicValue, catOpts: Opt[] = [], + catAtts: Att[] = [], ) { // ── Estado reactivo ─────────────────────────────────────────────────────── @@ -63,7 +64,7 @@ export function createConditionBuilderStore( : makeLogicNode('and', []) ); - const scope = $derived(buildScope(container, catOpts)); + const scope = $derived(buildScope(container, catOpts, catAtts)); const errors = $derived( validateCondition(root, scope) diff --git a/src/vcen-edit/tests/condition_editor.test.ts b/src/vcen-edit/tests/condition_editor.test.ts index 87cdfb1..1e7a008 100644 --- a/src/vcen-edit/tests/condition_editor.test.ts +++ b/src/vcen-edit/tests/condition_editor.test.ts @@ -299,6 +299,7 @@ describe('validateCondition', () => { function makeScope(...atts: [string, string][]): ScopeEntry[] { return atts.map(([path, kind]) => ({ path, + label: path.split('/').pop()!, att: makeAtt(path.split('/').pop()!), domain: { kind } as any, }));