-
{obj.name || 'Sin nombre'}
+
{labelOf(obj.label, cat.lang) || 'Sin nombre'}
{#if computedState}
@@ -61,13 +65,15 @@ const stateLabel: Record = {
{/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'