From cb74f27c5a0662406074a50236cf7d7001582b96 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 4 Mar 2026 01:55:55 +0100 Subject: [PATCH] . --- src/apps/svelte/App.svelte | 10 +- src/vice/adapters/svelte/ViceStore.svelte.ts | 138 ++++ .../adapters/svelte/vice_store_svelte.test.ts | 223 ++++++ src/vice/editor/comps/CollectionPanel.svelte | 233 +++++++ src/vice/editor/comps/Sidebar.svelte | 35 + src/vice/editor/comps/TopBar.svelte | 74 ++ src/vice/editor/comps/ViceEditor.svelte | 138 ++++ src/vice/editor/layouts/EditorLayout.svelte | 42 ++ ...Store.svelte.ts => EditorStore.svelte.tsa} | 0 src/vice/editor/stores/editor.svelte.ts | 639 ++++++++++++++++++ 10 files changed, 1527 insertions(+), 5 deletions(-) create mode 100644 src/vice/adapters/svelte/ViceStore.svelte.ts create mode 100644 src/vice/adapters/svelte/vice_store_svelte.test.ts create mode 100644 src/vice/editor/comps/CollectionPanel.svelte create mode 100644 src/vice/editor/comps/Sidebar.svelte create mode 100644 src/vice/editor/comps/TopBar.svelte create mode 100644 src/vice/editor/comps/ViceEditor.svelte create mode 100644 src/vice/editor/layouts/EditorLayout.svelte rename src/vice/editor/stores/{EditorStore.svelte.ts => EditorStore.svelte.tsa} (100%) create mode 100644 src/vice/editor/stores/editor.svelte.ts diff --git a/src/apps/svelte/App.svelte b/src/apps/svelte/App.svelte index ece5058..b594e95 100644 --- a/src/apps/svelte/App.svelte +++ b/src/apps/svelte/App.svelte @@ -1,15 +1,15 @@
- +
- \ No newline at end of file diff --git a/src/vice/adapters/svelte/ViceStore.svelte.ts b/src/vice/adapters/svelte/ViceStore.svelte.ts new file mode 100644 index 0000000..5761fc5 --- /dev/null +++ b/src/vice/adapters/svelte/ViceStore.svelte.ts @@ -0,0 +1,138 @@ +// ───────────────────────────────────────────────────────────────────────────── +// VICE · Svelte Adapter +// +// Envuelve ViceEngine con $state de Svelte 5. +// Proporciona un store reactivo para el configurador. +// ───────────────────────────────────────────────────────────────────────────── + +import { createViceEngine } from '@/vice/libs/./engine.ts' +import type { + ViceSession, + Collection, + EntityID, + OptionID, + EntityPathKey, + LoadResult, + SessionState, + SelectResult, + DeselectResult, + PropagationResult, + DesignIssue +} from '@/vice/libs' + + + +// ───────────────────────────────────────────────────────────────────────────── +// VICE STORE +// ───────────────────────────────────────────────────────────────────────────── + +export function createViceStore(collection: Collection, entityId: EntityID) { + + const engine = createViceEngine() + const loadResult = engine.load(collection, entityId) + + if (!loadResult.valid) { + const errors = loadResult.issues + .filter((i : DesignIssue) => i.severity === 'error') + .map((i: DesignIssue) => i.message) + .join('\n') + throw new Error(`[VICE] Cannot create store — graph has errors:\n${errors}`) + } + + const session: ViceSession = engine.createSession() + + // ── Reactive state ───────────────────────────────────────────────────────── + + let state = $state(session.getState()) + let lastResult = $state(null) + let lastError = $state(null) + + // ── Actions ──────────────────────────────────────────────────────────────── + + function select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult { + const result = session.select(path, optionId, quantity) + if (result.ok) { + state = result.result.state + lastResult = result.result + lastError = null + } else { + lastError = result + lastResult = null + } + return result + } + + function deselect(path: EntityPathKey): DeselectResult { + const result = session.deselect(path) + if (result.ok) { + state = result.result.state + lastResult = result.result + lastError = null + } else { + lastError = result + lastResult = null + } + return result + } + + function reset(): SessionState { + const s = session.reset() + state = s + lastResult = null + lastError = null + return s + } + + // ── Derived helpers ──────────────────────────────────────────────────────── + + const consistent = $derived(state.consistent) + const hasViolations = $derived( + [...state.attributes.values()].some(a => a.violations.length > 0) + ) + const violations = $derived( + [...state.attributes.values()].flatMap(a => + a.violations.map(v => ({ ...v, path: a.path, attrId: a.attrId })) + ) + ) + + // ── Public API ───────────────────────────────────────────────────────────── + + return { + // Load info + loadResult: loadResult as LoadResult, + + // Reactive state + get state() { return state }, + get consistent() { return consistent }, + get hasViolations() { return hasViolations }, + get violations() { return violations }, + get lastResult() { return lastResult }, + get lastError() { return lastError }, + + // Actions + select, + deselect, + reset, + + // Convenience accessors + getAttribute(path: EntityPathKey) { + return state.attributes.get(path) ?? null + }, + getGroup(path: EntityPathKey) { + return state.groups.get(path) ?? null + }, + isActive(path: EntityPathKey): boolean { + return state.attributes.get(path)?.active + ?? state.groups.get(path)?.active + ?? false + }, + currentOption(path: EntityPathKey): OptionID | null { + return state.attributes.get(path)?.current?.optionId ?? null + }, + domain(path: EntityPathKey): ReadonlySet { + return state.attributes.get(path)?.domain ?? new Set() + }, + } +} + +export type ViceStore = ReturnType \ No newline at end of file diff --git a/src/vice/adapters/svelte/vice_store_svelte.test.ts b/src/vice/adapters/svelte/vice_store_svelte.test.ts new file mode 100644 index 0000000..9ff41f8 --- /dev/null +++ b/src/vice/adapters/svelte/vice_store_svelte.test.ts @@ -0,0 +1,223 @@ +// ───────────────────────────────────────────────────────────────────────────── +// VICE · ViceStore Tests +// ───────────────────────────────────────────────────────────────────────────── + +import { describe, it, expect, beforeEach } from 'vitest' +import { fixture } from '@/vice/libs//fixture.ts' +import { createViceStore } from './ViceStore.svelte.ts' +import type { ViceStore } from './ViceStore.svelte.ts' + +let vice: ViceStore + +beforeEach(() => { + vice = createViceStore(fixture, 'en_bmw430') +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Inicialización +// ───────────────────────────────────────────────────────────────────────────── + +describe('inicialización', () => { + it('DEBUG — violaciones tras seleccionar op_coupe', () => { + vice.select('en_bmw430/at_version', 'op_coupe' as any) + console.log('consistent:', vice.consistent) + console.log('violations:', vice.violations) + console.log('state attributes:') + for (const [path, attr] of vice.state.attributes) { + if (attr.violations.length > 0 || !attr.active) { + console.log(' ', path, '| active:', attr.active, '| domain:', [...attr.domain], '| violations:', attr.violations) + } + } + expect(true).toBe(true) + }) + + it('loadResult.valid = true para fixture válido', () => { + expect(vice.loadResult.valid).toBe(true) + }) + + it('lanza si el catálogo tiene errores de diseño', () => { + const bad = { + ...fixture, + entities: { + 'en_bad': { + id: 'en_bad' as any, + code: 'bad', + name: { es: 'Bad' }, + description: { es: '' }, + attributes: [{ + id: 'at_x' as any, + code: 'x', + type: 'required' as const, + name: { es: 'X' }, + description: { es: '' }, + display: {}, + default: 'op_inexistente' as any, + options: [{ optionId: 'op_blanco' as any }], + }], + groups: {}, + }, + }, + } + expect(() => createViceStore(bad as any, 'en_bad' as any)).toThrow('[VICE]') + }) + + it('estado inicial es consistente', () => { + expect(vice.consistent).toBe(true) + }) + + it('estado inicial sin violaciones', () => { + expect(vice.hasViolations).toBe(false) + }) + + it('at_version empieza con default op_berlina', () => { + expect(vice.currentOption('en_bmw430/at_version')).toBe('op_berlina') + }) + + it('gr_extras empieza inactivo', () => { + expect(vice.isActive('en_bmw430/gr_extras')).toBe(false) + }) + + it('gr_motor empieza activo', () => { + expect(vice.isActive('en_bmw430/gr_motor')).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// select +// ───────────────────────────────────────────────────────────────────────────── + +describe('select', () => { + + it('devuelve ok=true para selección válida', () => { + const result = vice.select('en_bmw430/at_version', 'op_coupe' as any) + expect(result.ok).toBe(true) + }) + + it('actualiza state tras selección', () => { + vice.select('en_bmw430/at_version', 'op_coupe' as any) + expect(vice.currentOption('en_bmw430/at_version')).toBe('op_coupe') + }) + + it('propaga — seleccionar coupe activa gr_extras', () => { + vice.select('en_bmw430/at_version', 'op_coupe' as any) + expect(vice.isActive('en_bmw430/gr_extras')).toBe(true) + }) + + it('propaga — seleccionar electrico restringe potencia', () => { + vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any) + const domain = vice.domain('en_bmw430/gr_motor/at_potencia') + expect(domain.has('op_150cv' as any)).toBe(false) + expect(domain.has('op_190cv' as any)).toBe(false) + }) + + it('devuelve ok=false con INVALID_PATH', () => { + const result = vice.select('en_bmw430/at_inexistente', 'op_x' as any) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('INVALID_PATH') + }) + + it('devuelve ok=false con INACTIVE_ATTRIBUTE', () => { + const result = vice.select('en_bmw430/gr_tecnologia/at_sonido', 'op_harman' as any) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('INACTIVE_ATTRIBUTE') + }) + + it('devuelve ok=false con OPTION_NOT_IN_DOMAIN', () => { + vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any) + const result = vice.select('en_bmw430/gr_motor/at_potencia', 'op_150cv' as any) + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('OPTION_NOT_IN_DOMAIN') + }) + + it('guarda lastError tras selección fallida', () => { + vice.select('en_bmw430/at_inexistente', 'op_x' as any) + expect(vice.lastError).not.toBeNull() + expect(vice.lastError?.ok).toBe(false) + }) + + it('limpia lastError tras selección exitosa', () => { + vice.select('en_bmw430/at_inexistente', 'op_x' as any) + vice.select('en_bmw430/at_version', 'op_coupe' as any) + expect(vice.lastError).toBeNull() + }) + + it('guarda lastResult tras selección exitosa', () => { + vice.select('en_bmw430/at_version', 'op_coupe' as any) + expect(vice.lastResult).not.toBeNull() + expect(vice.lastResult?.consistent).toBe(false) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// deselect +// ───────────────────────────────────────────────────────────────────────────── + +describe('deselect', () => { + + it('devuelve ok=false con INVALID_PATH', () => { + const result = vice.deselect('en_bmw430/at_inexistente') + expect(result.ok).toBe(false) + if (!result.ok) expect(result.reason).toBe('INVALID_PATH') + }) + + it('pone current = null tras deselect', () => { + vice.deselect('en_bmw430/at_version') + expect(vice.currentOption('en_bmw430/at_version')).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// reset +// ───────────────────────────────────────────────────────────────────────────── + +describe('reset', () => { + + it('vuelve al estado inicial', () => { + vice.select('en_bmw430/at_version', 'op_coupe' as any) + vice.select('en_bmw430/gr_motor/at_combustible', 'op_electrico' as any) + vice.reset() + + expect(vice.currentOption('en_bmw430/at_version')).toBe('op_berlina') + expect(vice.currentOption('en_bmw430/gr_motor/at_combustible')).toBe('op_gasolina') + expect(vice.isActive('en_bmw430/gr_extras')).toBe(false) + }) + + it('limpia lastResult y lastError tras reset', () => { + vice.select('en_bmw430/at_version', 'op_coupe' as any) + vice.reset() + expect(vice.lastResult).toBeNull() + expect(vice.lastError).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +describe('helpers', () => { + + it('getAttribute devuelve el estado del atributo', () => { + const attr = vice.getAttribute('en_bmw430/at_version') + expect(attr).not.toBeNull() + expect(attr?.attrId).toBe('at_version') + }) + + it('getAttribute devuelve null para path inexistente', () => { + expect(vice.getAttribute('en_bmw430/at_inexistente')).toBeNull() + }) + + it('getGroup devuelve el estado del grupo', () => { + const group = vice.getGroup('en_bmw430/gr_motor') + expect(group).not.toBeNull() + expect(group?.groupId).toBe('gr_motor') + }) + + it('domain devuelve set vacío para path inexistente', () => { + expect(vice.domain('en_bmw430/at_inexistente').size).toBe(0) + }) + + it('violations agrupa todas las violaciones con path', () => { + expect(Array.isArray(vice.violations)).toBe(true) + }) +}) + diff --git a/src/vice/editor/comps/CollectionPanel.svelte b/src/vice/editor/comps/CollectionPanel.svelte new file mode 100644 index 0000000..1cc337d --- /dev/null +++ b/src/vice/editor/comps/CollectionPanel.svelte @@ -0,0 +1,233 @@ + + +
+ + +
+

Configuración de la colección

+ {col.id} +
+ + +
+ {#each [ + { label: 'Entidades', value: stats.entities }, + { label: 'Grupos', value: stats.groups }, + { label: 'Atributos', value: stats.attributes }, + { label: 'Opciones', value: stats.options }, + ] as stat} +
+ {stat.value} + {stat.label} +
+ {/each} +
+ + +
+

Identificación

+ +
+ + patch('code', e.currentTarget.value)} + placeholder="viviendas_vice" + class="field-input font-mono text-xs" /> +
+ + +
+ +
+ {#each col.langs as lang} +
+ {lang} + patchI18n('name', lang, e.currentTarget.value)} + placeholder={lang === 'es' ? 'Viviendas VICE' : ''} + class="field-input flex-1" /> +
+ {/each} +
+
+ + +
+ +
+ {#each col.langs as lang} +
+ {lang} + +
+ {/each} +
+
+
+ + +
+

Idiomas soportados

+
+ {#each ALL_LANGS as lang} + {@const active = col.langs.includes(lang)} + {@const required = lang === 'es'} + + {/each} +
+

* Español es obligatorio y no puede desactivarse

+
+ + +
+

Rutas de imagen

+
+ + patch('basePath', e.currentTarget.value)} + placeholder="/renders/" + class="field-input font-mono text-xs" /> +
+
+ Los templates de imagen se resuelven como + basePath + template. + Cada grupo puede sobreescribir este valor. +
+
+ + + {#if editor.validation.errors.filter(e => e.path.startsWith('collection')).length > 0} +
+

Errores

+ {#each editor.validation.errors.filter(e => e.path.startsWith('collection')) as error} +
+

{error.message}

+

{error.path}

+
+ {/each} +
+ {/if} + + +
+

Importar / Resetear

+
+ + +
+ + {#if showImport} +
+ + {#if importError} +

{importError}

+ {/if} +
+ + +
+
+ {/if} +
+ +
\ No newline at end of file diff --git a/src/vice/editor/comps/Sidebar.svelte b/src/vice/editor/comps/Sidebar.svelte new file mode 100644 index 0000000..c41a76f --- /dev/null +++ b/src/vice/editor/comps/Sidebar.svelte @@ -0,0 +1,35 @@ + + + diff --git a/src/vice/editor/comps/TopBar.svelte b/src/vice/editor/comps/TopBar.svelte new file mode 100644 index 0000000..fd34786 --- /dev/null +++ b/src/vice/editor/comps/TopBar.svelte @@ -0,0 +1,74 @@ + + +
+ + +
+ VICE + | + + {ling.ts(editor.collection.name) || 'Nueva colección'} + + {#if editor.isDirty} + ● Sin guardar + {/if} +
+ + +
+ + + {#if !editor.validation.valid} + + + {editor.validation.errors.length} error{editor.validation.errors.length !== 1 ? 'es' : ''} + + {:else} + + + Válido + + {/if} + + + + + + + + + + +
+
\ No newline at end of file diff --git a/src/vice/editor/comps/ViceEditor.svelte b/src/vice/editor/comps/ViceEditor.svelte new file mode 100644 index 0000000..68e196d --- /dev/null +++ b/src/vice/editor/comps/ViceEditor.svelte @@ -0,0 +1,138 @@ + + + + + diff --git a/src/vice/editor/layouts/EditorLayout.svelte b/src/vice/editor/layouts/EditorLayout.svelte new file mode 100644 index 0000000..b0a3db4 --- /dev/null +++ b/src/vice/editor/layouts/EditorLayout.svelte @@ -0,0 +1,42 @@ + + +
+ + + +
+ + + +
+ {#if editor.activePanel === 'catalog'} + +
CatalogPanel — en construcción
+ {:else if editor.activePanel === 'objects'} + +
ObjectsPanel — en construcción
+ {:else if editor.activePanel === 'options'} + +
OptionsPanel — en construcción
+ {:else if editor.activePanel === 'rules'} + +
RulesPanel — en construcción
+ {:else if editor.activePanel === 'preview'} + +
PreviewPanel — en construcción
+ {:else if editor.activePanel === 'export'} + +
ExportPanel — en construcción
+ {/if} +
+ +
+
diff --git a/src/vice/editor/stores/EditorStore.svelte.ts b/src/vice/editor/stores/EditorStore.svelte.tsa similarity index 100% rename from src/vice/editor/stores/EditorStore.svelte.ts rename to src/vice/editor/stores/EditorStore.svelte.tsa diff --git a/src/vice/editor/stores/editor.svelte.ts b/src/vice/editor/stores/editor.svelte.ts new file mode 100644 index 0000000..6b956e4 --- /dev/null +++ b/src/vice/editor/stores/editor.svelte.ts @@ -0,0 +1,639 @@ +// ───────────────────────────────────────────────────────────────────────────── +// VICE · Editor Store +// +// Store principal del editor de catálogos. +// Gestiona el estado de edición de una Collection completa. +// Usa runas de Svelte 5 ($state, $derived). +// ───────────────────────────────────────────────────────────────────────────── +import type {LingString} from "@/ling"; +import type { + Collection, CollectionID, + Entity, EntityID, + Group, GroupID, + Attribute, AttrID, + AttributeOption, + OptionDefinition, OptionID, + Rule, + EntityPath, + EntityPathKey, OptionalGroup, FixedGroup, RequiredAttribute, +} from '@/vice/libs/types.ts' +import {defaultISOLocale} from "@/ling/consts.ts"; + +// ───────────────────────────────────────────────────────────────────────────── +// HELPERS +// ───────────────────────────────────────────────────────────────────────────── + +function uid(prefix: string): string { + return `${prefix}_${Math.random().toString(36).slice(2, 8)}` +} + +function emptyLing(es = '', en = ''): LingString { + return { es, en } +} + +function emptyCollection(): Collection { + return { + id: uid('col') as CollectionID, + code: '', + langs: [defaultISOLocale], + name: emptyLing(), + description: emptyLing(), + attributes: [], + options: {} as Record, + entities: {} as Record, + } +} + +function emptyEntity(id: EntityID): Entity { + return { + id, + code: '', + name: emptyLing(), + description: emptyLing(), + attributes: [], + groups: {} as Record, + rules: [], + } +} + +function emptyGroup(id: GroupID, kind: 'fixed' | 'optional' = 'fixed'): Group { + const base = { + id, + code: '', + name: emptyLing(), + description: emptyLing(), + attributes: [], + } + if (kind === 'optional') { + return { ...base, kind: 'optional', rules: [] } satisfies OptionalGroup + } + return { ...base, kind: 'fixed' } satisfies FixedGroup +} + +function emptyAttribute(id: AttrID): RequiredAttribute { + return { + id, + code: '', + name: emptyLing(), + description: emptyLing(), + type: 'required', + default: '' as OptionID, + options: [], + rules: [], + display: {}, + } +} + +function emptyOption(id: OptionID): OptionDefinition { + return { + id, + code: '', + name: emptyLing(), + description: emptyLing(), + tags: [], + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// VALIDATION +// ───────────────────────────────────────────────────────────────────────────── + +export type EditorPanel = 'catalog' | 'objects' | 'options' | 'rules' | 'preview' | 'export' + +export interface EditorIssue { + severity: 'error' | 'warning' + message: string + path: string +} + +export interface EditorValidation { + valid: boolean + errors: EditorIssue[] + warnings: EditorIssue[] +} + +function validateCollection(collection: Collection): EditorValidation { + const errors: EditorIssue[] = [] + const warnings: EditorIssue[] = [] + + // Catálogo + if (!collection.code) errors.push({ severity: 'error', message: 'El catálogo necesita un código', path: 'collection.code' }) + if (!getLing(collection.name)) errors.push({ severity: 'error', message: 'El catálogo necesita un nombre', path: 'collection.name' }) + + // Opciones globales + for (const opt of Object.values(collection.options)) { + if (!opt.code) errors.push({ severity: 'error', message: `Opción "${opt.id}" sin código`, path: `options.${opt.id}.code` }) + if (!getLing(opt.name)) errors.push({ severity: 'error', message: `Opción "${opt.id}" sin nombre`, path: `options.${opt.id}.name` }) + } + + // Entidades + for (const entity of Object.values(collection.entities)) { + if (!entity.code) errors.push({ severity: 'error', message: `Entidad "${entity.id}" sin código`, path: `entities.${entity.id}.code` }) + if (!getLing(entity.name)) errors.push({ severity: 'error', message: `Entidad "${entity.id}" sin nombre`, path: `entities.${entity.id}.name` }) + + // Grupos + for (const [groupId, group] of Object.entries(entity.groups)) { + if (!group.code) errors.push({ severity: 'error', message: `Grupo "${groupId}" sin código`, path: `entities.${entity.id}.groups.${groupId}.code` }) + if (!getLing(group.name)) errors.push({ severity: 'error', message: `Grupo "${groupId}" sin nombre`, path: `entities.${entity.id}.groups.${groupId}.name` }) + + // Atributos del grupo + for (const attr of group.attributes) { + validateAttribute(attr, `entities.${entity.id}.groups.${groupId}`, collection, errors, warnings) + } + } + + // Atributos globales de la entidad + for (const attr of entity.attributes) { + validateAttribute(attr, `entities.${entity.id}`, collection, errors, warnings) + } + } + + // Atributos globales del catálogo + for (const attr of collection.attributes) { + validateAttribute(attr, 'collection', collection, errors, warnings) + } + + return { valid: errors.length === 0, errors, warnings } +} + +function validateAttribute( + attr: Attribute, + scope: string, + collection: Collection, + errors: EditorIssue[], + warnings: EditorIssue[], +): void { + const path = `${scope}.attributes.${attr.id}` + if (!attr.code) errors.push({ severity: 'error', message: `Atributo "${attr.id}" sin código`, path: `${path}.code` }) + if (!getLing(attr.name)) errors.push({ severity: 'error', message: `Atributo "${attr.id}" sin nombre`, path: `${path}.name` }) + + if (attr.type !== 'fixed') { + if (attr.options.length === 0) { + warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin opciones`, path: `${path}.options` }) + } else { + // Verificar que el default existe en las opciones + const hasDefault = attr.options.some(o => o.optionId === attr.default) + if (!hasDefault) { + errors.push({ severity: 'error', message: `Atributo "${attr.id}": el valor por defecto no está en las opciones`, path: `${path}.default` }) + } + // Verificar que todas las opciones existen en la librería + for (const attrOpt of attr.options) { + if (!collection.options[attrOpt.optionId]) { + errors.push({ severity: 'error', message: `Atributo "${attr.id}": opción "${attrOpt.optionId}" no existe en la librería`, path: `${path}.options.${attrOpt.optionId}` }) + } + } + } + } +} + +function getLing(l: LingString): string { + return typeof l === 'string' ? l : (l as any)?.es ?? '' +} + +// ───────────────────────────────────────────────────────────────────────────── +// EDITOR STORE +// ───────────────────────────────────────────────────────────────────────────── + +export function createEditorStore(initial?: Collection) { + + // ── State ────────────────────────────────────────────────────────────────── + + let collection = $state(initial ?? emptyCollection()) + let activeEntityId = $state(null) + let activeGroupId = $state(null) + let activeAttrId = $state(null) + let activeOptionId = $state(null) + let activeRuleIdx = $state(null) + let activePanel = $state('objects') + let isDirty = $state(false) + + function markDirty() { isDirty = true } + + // ── Derived ──────────────────────────────────────────────────────────────── + + + const validation = $derived(isDirty + ? validateCollection(collection) + : { valid: true, errors: [] as EditorIssue[], warnings: [] as EditorIssue[] } + ) + + + const canPreview = $derived( + validation.valid && Object.keys(collection.entities).length > 0 + ) + const activeEntity = $derived(activeEntityId ? collection.entities[activeEntityId] ?? null : null) + const activeGroup = $derived( + activeEntity && activeGroupId ? (activeEntity.groups[activeGroupId] ?? null) : null + ) + const activeAttribute = $derived(() => { + if (!activeAttrId) return null + if (activeGroup) return activeGroup.attributes.find(a => a.id === activeAttrId) ?? null + if (activeEntity) return activeEntity.attributes.find(a => a.id === activeAttrId) ?? null + return collection.attributes.find(a => a.id === activeAttrId) ?? null + }) + const activeOption = $derived(activeOptionId ? collection.options[activeOptionId] ?? null : null) + + // ── Navigation ───────────────────────────────────────────────────────────── + + function setPanel(panel: EditorPanel) { + activePanel = panel + } + + function setActiveEntity(id: EntityID | null) { + activeEntityId = id + activeGroupId = null + activeAttrId = null + } + + function setActiveGroup(id: GroupID | null) { + activeGroupId = id + activeAttrId = null + } + + function setActiveAttribute(id: AttrID | null) { + activeAttrId = id + } + + function setActiveOption(id: OptionID | null) { + activeOptionId = id + } + + // ── Collection meta ──────────────────────────────────────────────────────── + + function updateCollectionMeta(patch: Partial>) { + collection = { ...collection, ...patch } + isDirty = true + } + + // ── Options library ──────────────────────────────────────────────────────── + + function addOption(): OptionID { + const id = uid('op') as OptionID + collection = { + ...collection, + options: { ...collection.options, [id]: emptyOption(id) }, + } + activeOptionId = id + markDirty() + return id + } + + function updateOption(id: OptionID, patch: Partial) { + const opt = collection.options[id] + if (!opt) return + collection = { + ...collection, + options: { ...collection.options, [id]: { ...opt, ...patch } }, + } + markDirty() + } + + function deleteOption(id: OptionID) { + const { [id]: _, ...rest } = collection.options + collection = { ...collection, options: rest as Record } + if (activeOptionId === id) activeOptionId = null + markDirty() + } + + function duplicateOption(id: OptionID): OptionID { + const opt = collection.options[id] + if (!opt) return id + const newId = uid('op') as OptionID + const name = typeof opt.name === 'string' + ? opt.name + ' (copia)' + : { ...(opt.name as any), es: ((opt.name as any).es ?? '') + ' (copia)' } + collection = { + ...collection, + options: { ...collection.options, [newId]: { ...opt, id: newId, code: opt.code + '_copy', name } }, + } + activeOptionId = newId + return newId + } + + // ── Entities ─────────────────────────────────────────────────────────────── + + function addEntity(): EntityID { + const id = uid('en') as EntityID + collection = { + ...collection, + entities: { ...collection.entities, [id]: emptyEntity(id) }, + } + activeEntityId = id + activeGroupId = null + activeAttrId = null + markDirty() + return id + } + + function updateEntity(id: EntityID, patch: Record) { + const entity = collection.entities[id] + if (!entity) return + collection = { + ...collection, + entities: { ...collection.entities, [id]: { ...entity, ...patch } as Entity }, + } + markDirty() + } + + function deleteEntity(id: EntityID) { + const { [id]: _, ...rest } = collection.entities + collection = { ...collection, entities: rest as Record } + if (activeEntityId === id) setActiveEntity(null) + markDirty() + } + + // ── Groups ───────────────────────────────────────────────────────────────── + + function addGroup(entityId: EntityID, kind: 'fixed' | 'optional' = 'fixed'): GroupID { + const entity = collection.entities[entityId] + if (!entity) return '' as GroupID + const id = uid('gr') as GroupID + collection = { + ...collection, + entities: { + ...collection.entities, + [entityId]: { + ...entity, + groups: { ...entity.groups, [id]: emptyGroup(id, kind) }, + }, + }, + } + activeGroupId = id + activeAttrId = null + markDirty() + return id + } + + function updateGroup(entityId: EntityID, groupId: GroupID, patch: Record) { + const entity = collection.entities[entityId] + const group = entity?.groups[groupId] + if (!entity || !group) return + collection = { + ...collection, + entities: { + ...collection.entities, + [entityId]: { + ...entity, + groups: { ...entity.groups, [groupId]: { ...group, ...patch } as Group }, + }, + }, + } + markDirty() + } + + function deleteGroup(entityId: EntityID, groupId: GroupID) { + const entity = collection.entities[entityId] + if (!entity) return + const { [groupId]: _, ...rest } = entity.groups + collection = { + ...collection, + entities: { + ...collection.entities, + [entityId]: { ...entity, groups: rest as Record }, + }, + } + if (activeGroupId === groupId) { activeGroupId = null; activeAttrId = null } + markDirty() + } + + // ── Attributes ───────────────────────────────────────────────────────────── + + function addAttribute(entityId: EntityID, groupId?: GroupID): AttrID { + const id = uid('at') as AttrID + const attr = emptyAttribute(id) + + if (groupId) { + collection = produceAttr(collection, entityId, groupId, id, () => attr) + // produceAttr filtra por attrId que no existe aún — necesitamos append + const entity = collection.entities[entityId] + const group = entity?.groups[groupId] + if (entity && group) { + collection = { + ...collection, + entities: { + ...collection.entities, + [entityId]: { + ...entity, + groups: { + ...entity.groups, + [groupId]: { ...group, attributes: [...group.attributes, attr] }, + }, + }, + }, + } + } + } else { + const entity = collection.entities[entityId] + if (entity) { + collection = { + ...collection, + entities: { + ...collection.entities, + [entityId]: { ...entity, attributes: [...entity.attributes, attr] }, + }, + } + } + } + + activeAttrId = id + return id + } + + function updateAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, patch: Record) { + collection = produceAttr(collection, entityId, groupId, attrId, attr => ({ ...attr, ...patch }) as Attribute) + markDirty() + } + + function deleteAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID) { + collection = produceAttr(collection, entityId, groupId, attrId, null) + if (activeAttrId === attrId) activeAttrId = null + markDirty() + } + + // ── Attribute options ────────────────────────────────────────────────────── + + function addOptionToAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, optionId: OptionID) { + collection = produceAttr(collection, entityId, groupId, attrId, attr => { + if (attr.type === 'fixed') return attr + if (attr.options.some(o => o.optionId === optionId)) return attr + const newOpt: AttributeOption = { optionId } + const options = [...attr.options, newOpt] + const updated = { ...attr, options } + if (!attr.default && options.length === 1) (updated as any).default = optionId + return updated + }) + markDirty() + } + + function removeOptionFromAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID, optionId: OptionID) { + collection = produceAttr(collection, entityId, groupId, attrId, attr => { + if (attr.type === 'fixed') return attr + const options = attr.options.filter(o => o.optionId !== optionId) + const updated = { ...attr, options } + if ((attr as any).default === optionId) (updated as any).default = options[0]?.optionId ?? '' + return updated + }) + markDirty() + } + + // ── Catalog attributes ───────────────────────────────────────────────────── + + function addCatalogAttribute(): AttrID { + const id = uid('at') as AttrID + const attr = emptyAttribute(id) + collection = { ...collection, attributes: [...collection.attributes, attr] } + activeAttrId = id + activeEntityId = null + activeGroupId = null + return id + } + + function updateCatalogAttribute(attrId: AttrID, patch: Record) { + collection = { + ...collection, + attributes: collection.attributes.map(a => + a.id === attrId ? { ...a, ...patch } as Attribute : a + ), + } + } + + function deleteCatalogAttribute(attrId: AttrID) { + collection = { ...collection, attributes: collection.attributes.filter(a => a.id !== attrId) } + if (activeAttrId === attrId) activeAttrId = null + } + + // ── Import / Export ──────────────────────────────────────────────────────── + + function exportToJson(): string { + return JSON.stringify(collection, null, 2) + } + + function importFromJson(json: string) { + const parsed = JSON.parse(json) + collection = parsed + activeEntityId = null + activeGroupId = null + activeAttrId = null + activeOptionId = null + isDirty = false + } + + function reset() { + collection = emptyCollection() + activeEntityId = null + activeGroupId = null + activeAttrId = null + activeOptionId = null + isDirty = false + } + + // ── Public API ───────────────────────────────────────────────────────────── + + return { + // State (getters) + get collection() { return collection }, + get validation() { return validation }, + get activePanel() { return activePanel }, + get isDirty() { return isDirty }, + get canPreview() { return canPreview }, + get activeEntityId() { return activeEntityId }, + get activeGroupId() { return activeGroupId }, + get activeAttrId() { return activeAttrId }, + get activeOptionId() { return activeOptionId }, + get activeEntity() { return activeEntity }, + get activeGroup() { return activeGroup }, + get activeAttribute() { return activeAttribute() }, + get activeOption() { return activeOption }, + + // Navigation + setPanel, + setActiveEntity, + setActiveGroup, + setActiveAttribute, + setActiveOption, + + // Collection + updateCollectionMeta, + + // Options + addOption, + updateOption, + deleteOption, + duplicateOption, + + // Entities + addEntity, + updateEntity, + deleteEntity, + + // Groups + addGroup, + updateGroup, + deleteGroup, + + // Attributes + addAttribute, + updateAttribute, + deleteAttribute, + addCatalogAttribute, + updateCatalogAttribute, + deleteCatalogAttribute, + + // Attribute options + addOptionToAttribute, + removeOptionFromAttribute, + + // Import/Export + exportToJson, + importFromJson, + reset, + } +} + +export type EditorStore = ReturnType + +// ───────────────────────────────────────────────────────────────────────────── +// HELPERS INTERNOS — produce inmutable +// ───────────────────────────────────────────────────────────────────────────── + +function produceAttr( + collection: Collection, + entityId: EntityID, + groupId: GroupID | undefined, + attrId: AttrID, + fn: ((attr: Attribute) => Attribute) | null, +): Collection { + const entity = collection.entities[entityId] + if (!entity) return collection + + function mapAttrs(attrs: Attribute[]): Attribute[] { + if (fn === null) return attrs.filter(a => a.id !== attrId) + return attrs.map(a => a.id === attrId ? fn(a) as Attribute : a) + } + + if (groupId) { + const group = entity.groups[groupId] + if (!group) return collection + return { + ...collection, + entities: { + ...collection.entities, + [entityId]: { + ...entity, + groups: { + ...entity.groups, + [groupId]: { ...group, attributes: mapAttrs(group.attributes) }, + }, + }, + }, + } + } + + return { + ...collection, + entities: { + ...collection.entities, + [entityId]: { ...entity, attributes: mapAttrs(entity.attributes) }, + }, + } +} \ No newline at end of file