From 9c0e24dab1395407eada29ae42e310b105b3748d Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 11 Mar 2026 02:11:17 +0100 Subject: [PATCH] . --- index.html | 2 +- package.json | 15 +- src/glob/lib/curr_engine.ts | 4 +- src/glob/lib/dati_engine.ts | 4 +- src/glob/lib/numr_engine.ts | 4 +- src/glob/lib/unit_engine.ts | 2 +- src/ling/lib/consts.ts | 8 +- src/ling/lib/engine.ts | 19 +- src/ling/lib/guards.ts | 6 +- src/ling/lib/helpers.ts | 18 + src/ling/lib/index.ts | 1 + src/ling/lib/types.ts | 4 +- src/ling/svelte/LingInput.svelte | 4 +- src/ling/svelte/LingInput2.svelte | 98 +-- src/mtas/lib/builders.ts | 13 +- src/mtas/lib/guards.ts | 2 +- src/mtas/lib/types.ts | 18 +- src/mtas/lib/validation.ts | 132 +-- .../svelte/components/AddFieldDrawer.svelte | 162 ++++ src/mtas/svelte/components/FieldEditor.svelte | 218 ++--- src/mtas/svelte/components/FieldRow.svelte | 459 +++++----- .../svelte/components/FieldTypeIcon.svelte | 96 +++ .../svelte/components/MetadataEditor.svelte | 330 ++++--- src/mtas/svelte/components/TypeBadge.svelte | 53 +- src/mtas/svelte/demo/App.svelte | 95 +- src/mtas/svelte/demo/app.css | 1 + src/vice/adapters/svelte/ViceStore.svelte.ts | 138 --- .../adapters/svelte/vice_store_svelte.test.ts | 223 ----- src/vice/editor/comps/AttributeDetail.svelte | 378 -------- src/vice/editor/comps/AttributeList.svelte | 141 --- src/vice/editor/comps/CollectionPanel.svelte | 339 -------- src/vice/editor/comps/ConditionBuilder.svelte | 431 ---------- src/vice/editor/comps/EntitiesPanel.svelte | 55 -- .../editor/comps/EntityAttributeList.svelte | 160 ---- src/vice/editor/comps/EntityList.svelte | 89 -- src/vice/editor/comps/EntityMetaForm.svelte | 64 -- src/vice/editor/comps/GroupList.svelte | 187 ---- src/vice/editor/comps/GroupMetaForm.svelte | 79 -- src/vice/editor/comps/OptionDetail.svelte | 219 ----- src/vice/editor/comps/OptionsPanel.svelte | 130 --- src/vice/editor/comps/RuleEditor.svelte | 240 ------ src/vice/editor/comps/RuleList.svelte | 78 -- src/vice/editor/comps/Sidebar.svelte | 35 - src/vice/editor/comps/TopBar.svelte | 74 -- src/vice/editor/comps/ViceEditor.svelte | 191 ---- src/vice/editor/layouts/EditorLayout.svelte | 44 - src/vice/editor/stores/EditorStore.svelte.tsa | 606 ------------- src/vice/editor/stores/editor.svelte.ts | 812 ------------------ src/vice/libs/engine.ts | 121 --- src/vice/libs/engine_types.ts | 27 - src/vice/libs/gac.ts | 343 -------- src/vice/libs/graph_builder.ts | 252 ------ src/vice/libs/index.ts | 8 - src/vice/libs/session.ts | 121 --- src/vice/libs/state_types.ts | 95 -- src/vice/libs/validator.ts | 164 ---- src/vice/libs/validator_types.ts | 27 - 57 files changed, 1081 insertions(+), 6558 deletions(-) create mode 100644 src/ling/lib/helpers.ts create mode 100644 src/mtas/svelte/components/AddFieldDrawer.svelte create mode 100644 src/mtas/svelte/components/FieldTypeIcon.svelte delete mode 100644 src/vice/adapters/svelte/ViceStore.svelte.ts delete mode 100644 src/vice/adapters/svelte/vice_store_svelte.test.ts delete mode 100644 src/vice/editor/comps/AttributeDetail.svelte delete mode 100644 src/vice/editor/comps/AttributeList.svelte delete mode 100644 src/vice/editor/comps/CollectionPanel.svelte delete mode 100644 src/vice/editor/comps/ConditionBuilder.svelte delete mode 100644 src/vice/editor/comps/EntitiesPanel.svelte delete mode 100644 src/vice/editor/comps/EntityAttributeList.svelte delete mode 100644 src/vice/editor/comps/EntityList.svelte delete mode 100644 src/vice/editor/comps/EntityMetaForm.svelte delete mode 100644 src/vice/editor/comps/GroupList.svelte delete mode 100644 src/vice/editor/comps/GroupMetaForm.svelte delete mode 100644 src/vice/editor/comps/OptionDetail.svelte delete mode 100644 src/vice/editor/comps/OptionsPanel.svelte delete mode 100644 src/vice/editor/comps/RuleEditor.svelte delete mode 100644 src/vice/editor/comps/RuleList.svelte delete mode 100644 src/vice/editor/comps/Sidebar.svelte delete mode 100644 src/vice/editor/comps/TopBar.svelte delete mode 100644 src/vice/editor/comps/ViceEditor.svelte delete mode 100644 src/vice/editor/layouts/EditorLayout.svelte delete mode 100644 src/vice/editor/stores/EditorStore.svelte.tsa delete mode 100644 src/vice/editor/stores/editor.svelte.ts delete mode 100644 src/vice/libs/engine.ts delete mode 100644 src/vice/libs/engine_types.ts delete mode 100644 src/vice/libs/gac.ts delete mode 100644 src/vice/libs/graph_builder.ts delete mode 100644 src/vice/libs/index.ts delete mode 100644 src/vice/libs/session.ts delete mode 100644 src/vice/libs/state_types.ts delete mode 100644 src/vice/libs/validator.ts delete mode 100644 src/vice/libs/validator_types.ts diff --git a/index.html b/index.html index fdeaa55..d4b60b9 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/package.json b/package.json index a2c215c..48572b8 100644 --- a/package.json +++ b/package.json @@ -16,24 +16,23 @@ ], "author": "ACTIVE THING", "license": "EULA", - "dependencies": { - "svelte": "^5.53.6", - "@tailwindcss/vite": "^4.1.18" + "@tailwindcss/vite": "^4.1.18", + "daisyui": "^5.5.19", + "svelte": "^5.53.6" }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^6.2.4", + "@sveltejs/vite-plugin-svelte": "^6.2.4", "@tailwindcss/postcss": "^4.1.18", - "@tsconfig/svelte": "^5.0.6", "@testing-library/jest-dom": "^6.9.1", "@testing-library/svelte": "^5.3.1", + "@tsconfig/svelte": "^5.0.6", "@types/node": "^25.3.3", - "jsdom": "^28.1.0", - "ts-node": "^10.9.2", "autoprefixer": "^10.4.23", + "jsdom": "^28.1.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", + "ts-node": "^10.9.2", "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-singlefile": "^2.3.0", diff --git a/src/glob/lib/curr_engine.ts b/src/glob/lib/curr_engine.ts index e287d38..3249965 100644 --- a/src/glob/lib/curr_engine.ts +++ b/src/glob/lib/curr_engine.ts @@ -2,7 +2,7 @@ import type { CurrInstance, CurrDefinition, CurrDisplay, CurrConfig, ConversionV import type { LingInstance, LingNode } from '@/ling/lib/types.ts'; import type { LocaleResolver, GlobLocale } from './comn_types.ts'; import { resolveLocale } from './comn_engine.ts'; -import { defaultISOLocale } from '@/ling/lib/consts.ts'; +import { DEFAULT_ISO_LOCALE } from '@/ling/lib/consts.ts'; /** @@ -17,7 +17,7 @@ export function createCurr( definitions : Record, ling : LingInstance, opts : CurrConfig = {}, - defaultLocale: LocaleResolver = defaultISOLocale + defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE ): CurrInstance { let selectedCurrency: string = opts.selectedCurrency ?? Object.keys(definitions)[0] ?? ''; diff --git a/src/glob/lib/dati_engine.ts b/src/glob/lib/dati_engine.ts index 8ddca9e..03031a4 100644 --- a/src/glob/lib/dati_engine.ts +++ b/src/glob/lib/dati_engine.ts @@ -1,7 +1,7 @@ import type { DatiInstance, DatiOptions, DatiStyleType, DatiTimeFormat, DatiDateOrder } from './dati_types.ts'; import type { LocaleResolver, GlobLocale } from './comn_types.ts'; import { resolveLocale } from './comn_engine.ts'; -import { defaultISOLocale } from '@/ling/lib/consts.ts'; +import { DEFAULT_ISO_LOCALE } from '@/ling/lib/consts.ts'; // ============================== @@ -37,7 +37,7 @@ function resolveOpts(opts?: DatiOptions): { */ export function createDati( opts : { selectedTimeFormat?: DatiTimeFormat; selectedDateOrder?: DatiDateOrder } = {}, - defaultLocale: LocaleResolver = defaultISOLocale + defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE ): DatiInstance { let timeFormat: DatiTimeFormat = opts.selectedTimeFormat ?? '24h'; diff --git a/src/glob/lib/numr_engine.ts b/src/glob/lib/numr_engine.ts index 8e787f6..e534825 100644 --- a/src/glob/lib/numr_engine.ts +++ b/src/glob/lib/numr_engine.ts @@ -1,4 +1,4 @@ -import { defaultISOLocale } from '@/ling/lib/consts.ts'; +import { DEFAULT_ISO_LOCALE } from '@/ling/lib/consts.ts'; import { resolveLocale } from './comn_engine.ts'; import type { LocaleResolver } from './comn_types.ts'; import type { NumrInstance } from './numr_types.ts'; @@ -16,7 +16,7 @@ import type { NumrInstance } from './numr_types.ts'; */ export function createNumr( opts : { minDecimals?: number; maxDecimals?: number } = {}, - defaultLocale: LocaleResolver = defaultISOLocale + defaultLocale: LocaleResolver = DEFAULT_ISO_LOCALE ): NumrInstance { const defaults = { diff --git a/src/glob/lib/unit_engine.ts b/src/glob/lib/unit_engine.ts index 784ef65..edfff99 100644 --- a/src/glob/lib/unit_engine.ts +++ b/src/glob/lib/unit_engine.ts @@ -2,7 +2,7 @@ import type { LocaleResolver, GlobLocale } from "./comn_types.ts"; import type { UnitCategoryData, UnitCategoryInstance, UnitCategoryKey, UnitDisplay, UnitInstance, UnitSystem } from "./unit_types.ts"; import { resolveLocale } from "./comn_engine.ts"; -import { defaultISOLocale } from "@/ling/lib/consts.ts"; +import { DEFAULT_ISO_LOCALE } from "@/ling/lib/consts.ts"; import { type LingNode, type LingInstance, ling } from "@/ling/lib"; import { unitData } from "./unit_data.ts"; import { unitsLingNode } from "./unit_langs.ts"; diff --git a/src/ling/lib/consts.ts b/src/ling/lib/consts.ts index ceaef99..2745542 100644 --- a/src/ling/lib/consts.ts +++ b/src/ling/lib/consts.ts @@ -1,12 +1,12 @@ import type {LingLogger} from "@/ling/lib/types.ts"; -export const idPrefix = '#?'; +export const ID_PREFIX = '#?'; -export const defaultISOLocale = 'es' ; +export const DEFAULT_ISO_LOCALE = 'es' as const; -export const maxResolveDeep = 3; +export const MAX_RESOLVE_DEEP = 3; -export const loggerCategory = 'ling'; +export const LOGGER_CATEGORY = 'ling'; diff --git a/src/ling/lib/engine.ts b/src/ling/lib/engine.ts index 5be6f3b..bc2975b 100644 --- a/src/ling/lib/engine.ts +++ b/src/ling/lib/engine.ts @@ -9,18 +9,13 @@ import type { LingString, LingInstance, LingLogger, LingNode, PluralConfig, } from './types.ts'; import {LING_ERRORS} from "@/ling/lib/errors.ts"; -import {idPrefix, loggerCategory, maxResolveDeep} from "@/ling/lib/consts.ts"; +import {ID_PREFIX, LOGGER_CATEGORY, MAX_RESOLVE_DEEP} from "@/ling/lib/consts.ts"; import {isIDLing, isLingRecord} from "@/ling/lib/guards.ts"; import {pluralRule} from "@/ling/lib/plural-rules.ts"; +import {resolvePath} from "@/ling/lib/helpers.ts"; -// ============================== -// HELPERS -// ============================== -function resolvePath(obj: any, path: string): any { - return path.split('.').reduce((acc, key) => acc?.[key], obj); -} function isDev(): boolean { return typeof process !== 'undefined' && process.env?.NODE_ENV === 'development'; @@ -95,7 +90,7 @@ export function createLing( const msg = path ? LING_ERRORS.MISSING_TRANSLATION(path, currentLocale, defaultLocale) : LING_ERRORS.MISSING_TRANSLATION_RECORD(currentLocale, defaultLocale); - logger.warn(loggerCategory, msg); + logger.warn(LOGGER_CATEGORY, msg); } return translation; @@ -106,13 +101,13 @@ export function createLing( * de forma recursiva. Lanza si se supera el límite de profundidad. */ function resolveValue(value: any, args: any[], depth: number): any { - if (depth > maxResolveDeep) { - logger.error(loggerCategory, LING_ERRORS.CIRCULAR_REFERENCE(String(value))); + if (depth > MAX_RESOLVE_DEEP) { + logger.error(LOGGER_CATEGORY, LING_ERRORS.CIRCULAR_REFERENCE(String(value))); throw new Error("Circular reference in ling"); } if (isIDLing(value)) { - const path = value.substring(idPrefix.length); + const path = value.substring(ID_PREFIX.length); const resolved = resolvePath(currentSchema, path); return resolveValue(resolved, args, depth + 1); } @@ -132,7 +127,7 @@ export function createLing( const rawValue = resolvePath(currentSchema, path); if (rawValue === undefined) { - if (isDev()) logger.error(loggerCategory, LING_ERRORS.KEY_NOT_FOUND(path)); + if (isDev()) logger.error(LOGGER_CATEGORY, LING_ERRORS.KEY_NOT_FOUND(path)); return path; } diff --git a/src/ling/lib/guards.ts b/src/ling/lib/guards.ts index 1b17e0c..4a911be 100644 --- a/src/ling/lib/guards.ts +++ b/src/ling/lib/guards.ts @@ -1,5 +1,5 @@ import type {DefaultLocale, IDLing, LingRecord, LingString} from "@/ling/lib/types.ts"; -import {defaultISOLocale, idPrefix} from "@/ling/lib/consts.ts"; +import {DEFAULT_ISO_LOCALE, ID_PREFIX} from "@/ling/lib/consts.ts"; /** * Guard para identificar referencias. @@ -8,7 +8,7 @@ import {defaultISOLocale, idPrefix} from "@/ling/lib/consts.ts"; export function isIDLing(value: unknown): value is IDLing { return ( typeof value === 'string' && - value.startsWith(idPrefix) && + value.startsWith(ID_PREFIX) && value.length > 2 ); } @@ -24,7 +24,7 @@ export function isLingRecord(value: unknown): value is LingRecord { typeof value === 'object' && value !== null && !Array.isArray(value) && - defaultISOLocale in value // obligatorio según DefaultLocale + DEFAULT_ISO_LOCALE in value // obligatorio según DefaultLocale ); } diff --git a/src/ling/lib/helpers.ts b/src/ling/lib/helpers.ts new file mode 100644 index 0000000..3ac1701 --- /dev/null +++ b/src/ling/lib/helpers.ts @@ -0,0 +1,18 @@ +// ============================== +// HELPERS +// ============================== +import type {LingRecord} from "./types.ts"; +import {DEFAULT_ISO_LOCALE} from "@/ling/lib/consts.ts"; + +export function resolvePath(obj: any, path: string): any { + return path.split('.').reduce((acc, key) => acc?.[key], obj); +} + + + +export function makeLingRecord (text?: string) : LingRecord { + return { + [DEFAULT_ISO_LOCALE]: text ?? '' + } as LingRecord; +} + diff --git a/src/ling/lib/index.ts b/src/ling/lib/index.ts index 02250c7..bab9166 100644 --- a/src/ling/lib/index.ts +++ b/src/ling/lib/index.ts @@ -3,6 +3,7 @@ export { translations } from './translations.ts'; export * from './types.ts'; export * from './engine.ts'; +export * from './helpers.ts'; export * from './instance.ts'; export * from './translations.ts'; diff --git a/src/ling/lib/types.ts b/src/ling/lib/types.ts index c74054d..2703877 100644 --- a/src/ling/lib/types.ts +++ b/src/ling/lib/types.ts @@ -2,13 +2,13 @@ // LOCALES // ============================== -import type {idPrefix} from "@/ling/lib/consts.ts"; +import type {ID_PREFIX} from "@/ling/lib/consts.ts"; import type {PluralCategory} from "@/ling/lib/plural-rules.ts"; /** * Referencia interna: #?path.del.schema */ -export type IDLing = `${typeof idPrefix}${string}`; +export type IDLing = `${typeof ID_PREFIX}${string}`; export type DefaultLocale = 'es'; diff --git a/src/ling/svelte/LingInput.svelte b/src/ling/svelte/LingInput.svelte index 58f7340..a2af061 100644 --- a/src/ling/svelte/LingInput.svelte +++ b/src/ling/svelte/LingInput.svelte @@ -1,6 +1,6 @@ -
+
{#if label}
-
{/if} -
+
-
-
- {#each locales as locale (locale)} - {#if localeTab} - {@render localeTab({ locale, active: currentLocale === locale, hasContent: hasContent(locale), activate: () => activateLocale(locale) })} - {:else} - {@const active = currentLocale === locale} - {@const filled = hasContent(locale)} - {@const isDef = locale === defaultLocale} - - {/if} - {/each} -
+
+ {#each locales as locale (locale)} + {#if localeTab} + {@render localeTab({ locale, active: currentLocale === locale, hasContent: hasContent(locale), activate: () => activateLocale(locale) })} + {:else} + {@const active = currentLocale === locale} + {@const filled = hasContent(locale)} + + {/if} + {/each} + {#if charCount > 0} - {charCount} + + {charCount} + {/if}
+ {#if asType === 'textarea'} {:else} focused = true} - onblur={() => focused = false} - class="h-11 w-full bg-transparent px-4 text-[14px] text-zinc-800 - placeholder:text-zinc-300 focus:outline-none - dark:text-zinc-200 dark:placeholder:text-zinc-600" + class="input w-full rounded-none border-none bg-base-100 focus:outline-none" /> {/if} + {#if invalid} -
- +
+ - + El campo {defaultLocale.toUpperCase()} es obligatorio
diff --git a/src/mtas/lib/builders.ts b/src/mtas/lib/builders.ts index c611c14..cdc9efb 100644 --- a/src/mtas/lib/builders.ts +++ b/src/mtas/lib/builders.ts @@ -1,10 +1,10 @@ -import type {LingString} from "@/ling/lib"; +import type {LingRecord, LingString} from "@/ling/lib"; import type { Metadata, MetadataField, CurrencyValue, MediaRef, - StringField, NumberField, BooleanField, LingStringField, + StringField, NumberField, BooleanField, LingField, DateField, DateTimeField, UrlField, CurrencyField, - ImageField, VideoField, AudioField, PdfField, FileField, GroupField, + ImageField, VideoField, AudioField, PdfField, FileField, GroupField, HtmlField, } from './types.ts'; @@ -15,7 +15,7 @@ import type { export const string = (value: string): StringField => ({ type: 'string', value }); export const number = (value: number): NumberField => ({ type: 'number', value }); export const boolean = (value: boolean): BooleanField => ({ type: 'boolean', value }); -export const lingString = (value: LingString): LingStringField => ({ type: 'lingstring', value }); +export const ling = (value: LingRecord): LingField => ({ type: 'ling', value }); export const date = (value: string): DateField => ({ type: 'date', value }); export const datetime = (value: string): DateTimeField => ({ type: 'datetime', value }); export const url = (value: string): UrlField => ({ type: 'url', value }); @@ -25,12 +25,13 @@ export const video = (value: MediaRef): VideoField => ({ type: 'v export const audio = (value: MediaRef): AudioField => ({ type: 'audio', value }); export const pdf = (value: MediaRef): PdfField => ({ type: 'pdf', value }); export const file = (value: MediaRef): FileField => ({ type: 'file', value }); +export const html = (value: LingRecord): HtmlField => ({ type: 'html', value }); export const group = (value: Metadata, order?: string[]): GroupField => ({ type: 'group', value, ...(order !== undefined && { order }) }); /** Namespace agrupado para importar como `field.string(...)`, `field.group(...)`, etc. */ export const field = { - string, number, boolean, lingstring: lingString, date, datetime, - url, currency, image, video, audio, pdf, file, group, + string, number, boolean, ling, date, datetime, + url, currency, image, video, audio, pdf, file, html, group, } as const; // ───────────────────────────────────────────── diff --git a/src/mtas/lib/guards.ts b/src/mtas/lib/guards.ts index bffbc27..6e766a4 100644 --- a/src/mtas/lib/guards.ts +++ b/src/mtas/lib/guards.ts @@ -23,7 +23,7 @@ export function isValidUrl(value: string): boolean { } const KNOWN_TYPES: MetadataFieldType[] = [ - 'string','number','boolean','lingstring','date','datetime', + 'string','number','boolean','ling','date','datetime', 'url','currency','image','video','audio','pdf','file','group', ]; diff --git a/src/mtas/lib/types.ts b/src/mtas/lib/types.ts index 7cc9759..3c7de31 100644 --- a/src/mtas/lib/types.ts +++ b/src/mtas/lib/types.ts @@ -1,4 +1,4 @@ -import type {LingString} from "@/ling/lib"; +import type {LingRecord, LingString} from "@/ling/lib"; import type {CURRENT_VERSION} from "@/mtas/lib/version.ts"; // ───────────────────────────────────────────── @@ -12,7 +12,10 @@ import type {CURRENT_VERSION} from "@/mtas/lib/version.ts"; * Los valores son MetadataField (discriminated union por 'type'). * Un valor puede ser otro Metadata (grupo anidado via GroupField). */ -export type Metadata = Record; +export type Metadata = { + [key: string]: unknown; + order?: string[]; +}; @@ -33,7 +36,7 @@ export interface MediaRef { mimeType?: string; size? : number; // bytes filename?: string; - alt? : LingString; + alt? : LingRecord; } // ───────────────────────────────────────────── @@ -44,14 +47,15 @@ export interface MediaRef { interface FieldBase { type : MetadataFieldType; hidden? : boolean; - // futuras: required?, disabled?, label?... + /** Atributos aplicados a este campo (subconjunto de los disponibles en el editor) */ + attrs?: string[]; } export interface StringField extends FieldBase { type: 'string'; value: string } export interface NumberField extends FieldBase { type: 'number'; value: number } export interface BooleanField extends FieldBase { type: 'boolean'; value: boolean } -export interface LingStringField extends FieldBase { type: 'lingstring'; value: LingString } +export interface LingField extends FieldBase { type: 'ling'; value: LingRecord } export interface DateField extends FieldBase { type: 'date'; value: string } // ISO 8601: "YYYY-MM-DD" export interface DateTimeField extends FieldBase { type: 'datetime'; value: string } // ISO 8601: "YYYY-MM-DDThh:mm:ssZ" export interface UrlField extends FieldBase { type: 'url'; value: string } @@ -61,6 +65,7 @@ export interface VideoField extends FieldBase { type: 'video'; value: export interface AudioField extends FieldBase { type: 'audio'; value: MediaRef } export interface PdfField extends FieldBase { type: 'pdf'; value: MediaRef } export interface FileField extends FieldBase { type: 'file'; value: MediaRef } +export interface HtmlField extends FieldBase { type: 'html'; value: LingRecord } /** * Grupo anidado — su value ES otro Metadata. @@ -86,7 +91,7 @@ export type MetadataField = | StringField | NumberField | BooleanField - | LingStringField + | LingField | DateField | DateTimeField | UrlField @@ -96,6 +101,7 @@ export type MetadataField = | AudioField | PdfField | FileField + | HtmlField | GroupField; diff --git a/src/mtas/lib/validation.ts b/src/mtas/lib/validation.ts index b7de2b4..691c222 100644 --- a/src/mtas/lib/validation.ts +++ b/src/mtas/lib/validation.ts @@ -29,57 +29,61 @@ const ISO_DATETIME_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/; // ───────────────────────────────────────────── function validateByType(key: string, field: MetadataField): ValidationResult { - switch (field.type) { - case 'string': - return ok(); - - case 'number': - return !isNaN(field.value) - ? ok() : fail(`[${key}] value must be a valid number`); - - case 'boolean': - return ok(); - - case 'lingstring': - return isLingString(field.value) - ? ok() : fail(`[${key}] value must be a LingString (string | LingRecord | IDLing)`); - - case 'date': - return ISO_DATE_RE.test(field.value) - ? ok() : fail(`[${key}] value must be an ISO 8601 date (YYYY-MM-DD)`); - - case 'datetime': - return ISO_DATETIME_RE.test(field.value) - ? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mm:ssZ)`); - - case 'url': - return isValidUrl(field.value) - ? ok() : fail(`[${key}] value must be a valid URL`); - - case 'currency': { - if (!isCurrencyValue(field.value)) return fail(`[${key}] value must be { amount: number, currency: string }`); - if (field.value.currency.length !== 3) return fail(`[${key}] currency must be a 3-letter ISO 4217 code`); - return ok(); - } - - case 'image': - case 'video': - case 'audio': - case 'pdf': - case 'file': { - if (!isMediaRef(field.value)) return fail(`[${key}] value must be a MediaRef with a non-empty 'url'`); - if (!isValidUrl(field.value.url)) return fail(`[${key}] MediaRef.url must be a valid URL`); - return ok(); - } - - case 'group': - return validateMetadata(field.value, key); - - default: { - const exhaustive: never = field; - return fail(`Unknown field type: ${(exhaustive as MetadataField).type}`); - } + switch (field.type) { + case 'string': + return ok(); + + case 'number': + return !isNaN(field.value) + ? ok() : fail(`[${key}] value must be a valid number`); + + case 'boolean': + return ok(); + + case 'ling': + return isLingString(field.value) + ? ok() : fail(`[${key}] value must be a LingString (string | LingRecord | IDLing)`); + + case 'date': + return ISO_DATE_RE.test(field.value) + ? ok() : fail(`[${key}] value must be an ISO 8601 date (YYYY-MM-DD)`); + + case 'datetime': + return ISO_DATETIME_RE.test(field.value) + ? ok() : fail(`[${key}] value must be an ISO 8601 datetime (YYYY-MM-DDThh:mm:ssZ)`); + + case 'url': + return isValidUrl(field.value) + ? ok() : fail(`[${key}] value must be a valid URL`); + + case 'currency': { + if (!isCurrencyValue(field.value)) return fail(`[${key}] value must be { amount: number, currency: string }`); + if (field.value.currency.length !== 3) return fail(`[${key}] currency must be a 3-letter ISO 4217 code`); + return ok(); + } + + case 'image': + case 'video': + case 'audio': + case 'pdf': + case 'file': { + if (!isMediaRef(field.value)) return fail(`[${key}] value must be a MediaRef with a non-empty 'url'`); + return ok(); // acepta cualquier string: relativa, absoluta, data:… + } + + case 'html': { + if (!isLingString(field.value)) return fail(`[${key}] html value must be a LingString`); + return ok(); + } + + case 'group': + return validateMetadata(field.value, key); + + default: { + const exhaustive: never = field; + return fail(`Unknown field type: ${(exhaustive as MetadataField).type}`); } + } } // ───────────────────────────────────────────── @@ -92,11 +96,11 @@ function validateByType(key: string, field: MetadataField): ValidationResult { * @param value - el valor en bruto (unknown) almacenado en el Record */ export function validateField(key: string, value: unknown): ValidationResult { - if (typeof key !== 'string' || key.trim() === '') - return fail('Field key must be a non-empty string'); - if (!isMetadataField(value)) - return fail(`[${key}] value must be a MetadataField with a known 'type'`); - return validateByType(key, value); + if (typeof key !== 'string' || key.trim() === '') + return fail('Field key must be a non-empty string'); + if (!isMetadataField(value)) + return fail(`[${key}] value must be a MetadataField with a known 'type'`); + return validateByType(key, value); } /** @@ -105,13 +109,15 @@ export function validateField(key: string, value: unknown): ValidationResult { * @param prefix - prefijo para los mensajes de error en grupos anidados */ export function validateMetadata(metadata: Metadata, prefix = ''): ValidationResult { - if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) - return fail(`${prefix ? `[${prefix}] ` : ''}Metadata must be a plain object`); - - const results = Object.entries(metadata).map(([key, value]) => { - const fullKey = prefix ? `${prefix}.${key}` : key; - return validateField(fullKey, value); + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) + return fail(`${prefix ? `[${prefix}] ` : ''}Metadata must be a plain object`); + + const results = Object.entries(metadata) + .filter(([key]) => key !== 'order') + .map(([key, value]) => { + const fullKey = prefix ? `${prefix}.${key}` : key; + return validateField(fullKey, value); }); - return mergeResults(...results); -} \ No newline at end of file + return mergeResults(...results); +} diff --git a/src/mtas/svelte/components/AddFieldDrawer.svelte b/src/mtas/svelte/components/AddFieldDrawer.svelte new file mode 100644 index 0000000..07311ae --- /dev/null +++ b/src/mtas/svelte/components/AddFieldDrawer.svelte @@ -0,0 +1,162 @@ + + + + + + + + + + diff --git a/src/mtas/svelte/components/FieldEditor.svelte b/src/mtas/svelte/components/FieldEditor.svelte index d1eb18d..822ac1f 100644 --- a/src/mtas/svelte/components/FieldEditor.svelte +++ b/src/mtas/svelte/components/FieldEditor.svelte @@ -1,168 +1,118 @@ -
+
-
- +
+ + Clave del campo + {#if keyError} + {keyError} + {/if} + e.key === 'Enter' && (e.currentTarget as HTMLInputElement).blur()} - class="{inputCls} font-mono" + value={editKey} + oninput={(e) => { editKey = (e.currentTarget as HTMLInputElement).value; keyError = ''; }} + onblur={handleKeyCommit} + onkeydown={handleKeydown} + class={['input input-bordered w-full font-mono', keyError ? 'input-error' : ''].join(' ')} placeholder="nombre_campo" /> -
- - - {#if field.type === 'string'} -
- - update({ value: (e.currentTarget as HTMLInputElement).value })} - class={inputCls} placeholder="Texto…" /> -
- - {:else if field.type === 'number'} -
- - update({ value: parseFloat((e.currentTarget as HTMLInputElement).value) })} - class={inputCls} /> -
- - {:else if field.type === 'boolean'} -
- -
- - - {field.value ? 'Verdadero' : 'Falso'} - -
-
- - {:else if field.type === 'lingstring'} - update({ value: val })} - /> - - {:else if field.type === 'date'} -
- - update({ value: (e.currentTarget as HTMLInputElement).value })} - class={inputCls} /> -
- - {:else if field.type === 'datetime'} -
- - update({ value: (e.currentTarget as HTMLInputElement).value + 'Z' })} - class={inputCls} /> -
- - {:else if field.type === 'url'} -
- - update({ value: (e.currentTarget as HTMLInputElement).value })} - class={inputCls} placeholder="https://… o /ruta/relativa" /> -
- - {:else if field.type === 'currency'} -
-
- - update({ value: { ...field.value, amount: parseFloat((e.currentTarget as HTMLInputElement).value) } })} - class={inputCls} /> -
-
- - update({ value: { ...field.value, currency: (e.currentTarget as HTMLInputElement).value.toUpperCase() } })} - class="{inputCls} font-mono uppercase" placeholder="EUR" /> + + + + + + + {#if attributes.length > 0} +
+
+ Atributos +
+ {#each attributes as attr (attr)} + {@const active = activeAttrs.includes(attr)} + + {/each}
- - {:else if field.type === 'image' || field.type === 'video' || field.type === 'audio' || field.type === 'pdf' || field.type === 'file'} -
- - updateMediaUrl((e.currentTarget as HTMLInputElement).value)} - class={inputCls} placeholder="/ruta/relativa o https://…" /> -
- {#if field.type === 'image' && field.value.url} -
- {fieldKey} -
- {/if} - - {:else if field.type === 'group'} -

- Edita los campos del grupo expandiéndolos en la lista. -

{/if} -
-
-

Ocultar campo

-

El campo no se mostrará en la UI

-
- +
+
+ Ocultar campo + update({ hidden: (e.currentTarget as HTMLInputElement).checked })} + class="toggle toggle-sm" /> + {asBase.hidden ? 'No se muestra en la UI' : 'Visible'}
-
\ No newline at end of file +
diff --git a/src/mtas/svelte/components/FieldRow.svelte b/src/mtas/svelte/components/FieldRow.svelte index dfcbcd8..efb34c2 100644 --- a/src/mtas/svelte/components/FieldRow.svelte +++ b/src/mtas/svelte/components/FieldRow.svelte @@ -3,44 +3,58 @@ import { validateField, field as f } from '../../lib'; import TypeBadge from './TypeBadge.svelte'; import FieldEditor from './FieldEditor.svelte'; - import FieldRowR from './FieldRow.svelte'; + import FieldRowRecursive from './FieldRow.svelte'; + import AddFieldDrawer from "@/mtas/svelte/components/AddFieldDrawer.svelte"; let { fieldKey, field, locales, defaultLocale, depth = 0, + attributes = [], + canMoveUp = false, + canMoveDown = false, onKeyChange, onFieldChange, onDelete, - onDragStart, onDragOver, onDrop, + onMoveUp, onMoveDown, }: { fieldKey: string; field: MetadataField; locales: string[]; defaultLocale: string; depth?: number; + attributes?: string[]; + canMoveUp?: boolean; + canMoveDown?: boolean; onKeyChange: (newKey: string) => void; onFieldChange: (field: MetadataField) => void; onDelete: () => void; - onDragStart: (e: DragEvent) => void; - onDragOver: (e: DragEvent) => void; - onDrop: (e: DragEvent) => void; + onMoveUp?: () => void; + onMoveDown?: () => void; } = $props(); - let expanded = $state(false); - let dragOver = $state(false); - let groupOpen = $state(true); + // ── State ──────────────────────────────────────────────────────── + let fieldBase = $derived(field as typeof field & { hidden?: boolean }); + let expanded = $state(false); + let groupOpen = $state(true); + let groupCollapseV = $state(0); // sube al colapsar → fuerza recrear hijos let childDragSrc = $state(null); - let childDragOver = $state(null); + let drawerOpen = $state(false); + let drawerType = $state('string'); - let validation = $derived(validateField(fieldKey, field)); + const ALL_TYPES: MetadataFieldType[] = [ + 'string','number','boolean','ling','date','datetime', + 'url','currency','image','video','audio','pdf','file','html','group' + ]; + + // ── Derived ────────────────────────────────────────────────────── + let validationResult = $derived(validateField(fieldKey, field)); + let validationErrors = $derived((validationResult as { valid: boolean; errors?: string[] }).errors ?? []); - // ── Summary value for collapsed row ───────────────────────────── let summary = $derived((() => { switch (field.type) { case 'string': return field.value || '—'; case 'number': return String(field.value); case 'boolean': return field.value ? 'Verdadero' : 'Falso'; - case 'lingstring': return typeof field.value === 'string' - ? field.value - : (Object.values(field.value as Record)[0] ?? '—'); + case 'ling': + case 'html': return Object.values(field.value as Record)[0] ?? '—'; case 'date': case 'datetime': return field.value || '—'; case 'url': return field.value || '—'; @@ -55,246 +69,277 @@ })()); let thumbUrl = $derived( - field.type === 'image' && (field.value as { url: string }).url - ? (field.value as { url: string }).url : null + field.type === 'image' ? (field.value as { url: string }).url || null : null ); - // ── Child group operations ─────────────────────────────────────── - function defaultChild(type: MetadataFieldType): MetadataField { - switch (type) { - case 'string': return f.string(''); - case 'number': return f.number(0); - case 'boolean': return f.boolean(false); - case 'lingstring': return f.lingstring({ [defaultLocale]: '' } as any); - case 'date': return f.date(''); - case 'datetime': return f.datetime(''); - case 'url': return f.url(''); - case 'currency': return f.currency({ amount: 0, currency: 'EUR' }); - case 'image': return f.image({ url: '' }); - case 'video': return f.video({ url: '' }); - case 'audio': return f.audio({ url: '' }); - case 'pdf': return f.pdf({ url: '' }); - case 'file': return f.file({ url: '' }); - case 'group': return f.group({}); - } - } - - function uniqueChildKey(type: MetadataFieldType): string { - if (field.type !== 'group') return type; - let n = 1; - while (`${type}_${n}` in field.value) n++; - return `${type}_${n}`; + // ── Group operations ───────────────────────────────────────────── + function openChildDrawer(type: MetadataFieldType): void { + drawerType = type; + drawerOpen = true; + groupOpen = true; } - function addChildField(type: MetadataFieldType) { + function handleDrawerConfirm(key: string, childField: MetadataField): void { if (field.type !== 'group') return; - const key = uniqueChildKey(type); - onFieldChange({ ...field, value: { ...field.value, [key]: defaultChild(type) } } as MetadataField); - groupOpen = true; + onFieldChange({ ...field, value: { ...field.value, [key]: childField } } as MetadataField); } - function updateChildField(childKey: string, childField: MetadataField) { + function updateChildField(childKey: string, childField: MetadataField): void { if (field.type !== 'group') return; onFieldChange({ ...field, value: { ...field.value, [childKey]: childField } } as MetadataField); } - function renameChildField(oldKey: string, newKey: string) { + function renameChildField(oldKey: string, newKey: string): void { if (field.type !== 'group' || newKey === oldKey || newKey in field.value) return; const entries = Object.entries(field.value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]); onFieldChange({ ...field, value: Object.fromEntries(entries) } as MetadataField); } - function deleteChildField(childKey: string) { + function deleteChildField(childKey: string): void { if (field.type !== 'group') return; const { [childKey]: _, ...rest } = field.value; onFieldChange({ ...field, value: rest } as MetadataField); } - function dropChildField(targetKey: string) { - if (field.type !== 'group' || !childDragSrc || childDragSrc === targetKey) return; + function moveChildField(childKey: string, dir: -1 | 1): void { + if (field.type !== 'group') return; const entries = Object.entries(field.value); - const srcIdx = entries.findIndex(([k]) => k === childDragSrc); - const tgtIdx = entries.findIndex(([k]) => k === targetKey); - if (srcIdx === -1 || tgtIdx === -1) return; + const idx = entries.findIndex(([k]) => k === childKey); + const newIdx = idx + dir; + if (newIdx < 0 || newIdx >= entries.length) return; const reordered = [...entries]; - const [moved] = reordered.splice(srcIdx, 1); - reordered.splice(tgtIdx, 0, moved); - childDragSrc = null; childDragOver = null; + const [moved] = reordered.splice(idx, 1); + reordered.splice(newIdx, 0, moved); onFieldChange({ ...field, value: Object.fromEntries(reordered) } as MetadataField); } + + // ── UI helpers ─────────────────────────────────────────────────── + function toggleExpanded(): void { + expanded = !expanded; + if (!expanded && field.type === 'group') { + groupOpen = false; + groupCollapseV++; + } + } + + function handleExpandKey(e: KeyboardEvent): void { + if (e.key === 'Enter') { + expanded = !expanded; + } else if (e.key === 'ArrowUp' && canMoveUp) { + e.preventDefault(); + onMoveUp?.(); + requestAnimationFrame(() => requestAnimationFrame(() => { + const row = document.getElementById(`field-row-${fieldKey}`); + row?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + row?.querySelector('[role="button"]')?.focus(); + })); + } else if (e.key === 'ArrowDown' && canMoveDown) { + e.preventDefault(); + onMoveDown?.(); + requestAnimationFrame(() => requestAnimationFrame(() => { + const row = document.getElementById(`field-row-${fieldKey}`); + row?.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + row?.querySelector('[role="button"]')?.focus(); + })); + } + } + function asField(v: unknown): MetadataField { return v as MetadataField; }
{ e.preventDefault(); dragOver = true; onDragOver(e); }} - ondragleave={() => dragOver = false} - ondrop={(e) => { dragOver = false; onDrop(e); }} + id="field-row-{fieldKey}" + role="listitem" + aria-label={fieldKey} class={[ - 'group/row overflow-hidden rounded-xl border-2 bg-white transition-all duration-150 dark:bg-zinc-900', + 'card bg-base-100 border transition-all duration-150 flex-row focus-within:border-base-content w-full', depth > 0 ? 'ml-5' : '', - dragOver - ? 'border-zinc-400 dark:border-zinc-500' - : validation.valid - ? expanded - ? 'border-zinc-900 dark:border-zinc-100' - : 'border-zinc-200 hover:border-zinc-300 dark:border-zinc-700 dark:hover:border-zinc-600' - : 'border-red-300 dark:border-red-700', + !validationResult.valid ? 'border-error' + : expanded ? 'border-base-300 shadow-sm' + : 'border-base-200 hover:border-base-300', ].join(' ')} > - -
expanded = !expanded} - onkeydown={(e) => e.key === 'Enter' && (expanded = !expanded)} - class={[ - 'flex cursor-pointer items-center gap-3 px-4 transition-colors duration-100', + + {#if depth === 0} +
+ + +
+ {/if} + +
+ +
- -
e.stopPropagation()}> - - - - -
+ > - - {#if thumbUrl} -
- {fieldKey} -
- {/if} + + - - + + - - - {fieldKey} - + + {fieldKey} - - - {summary} - + + {summary} - - {#if field.hidden} - - oculto - - {/if} + + {#if thumbUrl} +
+ {fieldKey} +
+ {/if} - -
+ + {#if fieldBase.hidden} + oculto + {/if} - -
e.stopPropagation()}> - - + +
+ + +
- - - - -
- - - {#if !validation.valid && (validation as any).errors?.length} -
- - - - {(validation as any).errors[0]} -
- {/if} - - - {#if expanded} -
- onFieldChange(f)} - /> -
- {/if} - - - {#if field.type === 'group'} -
- - -
- + + {#if !validationResult.valid && validationErrors.length} +
+ + {validationErrors[0]} +
+ {/if} -
+ + {#if expanded} +
+ onFieldChange(f)} + /> +
+ {/if} - - {#each (['string','number','boolean','lingstring','date','datetime','url','currency','image','video','audio','pdf','file','group'] as MetadataFieldType[]) as type} - - {/each} +
+ {#each ALL_TYPES as type (type)} + + {/each} +
+ {#if groupOpen} +
+ {#if Object.keys(field.value).length === 0} +
+ Grupo vacío — añade campos con los botones de arriba +
+ {:else} + {#each Object.keys(field.value) as childKey, ci (`${childKey}-${groupCollapseV}`)} + 0} + canMoveDown={ci < Object.keys(field.value).length - 1} + onKeyChange={(newKey) => renameChildField(childKey, newKey)} + onFieldChange={(f) => updateChildField(childKey, f)} + onDelete={() => deleteChildField(childKey)} + onMoveUp={() => moveChildField(childKey, -1)} + onMoveDown={() => moveChildField(childKey, 1)} + /> + {/each} + {/if} +
+ {/if}
+ {/if} +
+
- - {#if groupOpen} -
- {#if Object.keys(field.value).length === 0} -
- - Grupo vacío — añade campos con los botones de arriba - -
- {:else} - {#each Object.keys(field.value) as childKey (childKey)} - renameChildField(childKey, newKey)} - onFieldChange={(f) => updateChildField(childKey, f)} - onDelete={() => deleteChildField(childKey)} - onDragStart={(e) => { e.dataTransfer?.setData('text/plain', `${fieldKey}::${childKey}`); childDragSrc = childKey; }} - onDragOver={() => { childDragOver = childKey; }} - onDrop={() => dropChildField(childKey)} - /> - {/each} - {/if} -
- {/if} -
- {/if} -
\ No newline at end of file + +{#if field.type === 'group'} + drawerOpen = false} + /> +{/if} \ No newline at end of file diff --git a/src/mtas/svelte/components/FieldTypeIcon.svelte b/src/mtas/svelte/components/FieldTypeIcon.svelte new file mode 100644 index 0000000..0a1fc19 --- /dev/null +++ b/src/mtas/svelte/components/FieldTypeIcon.svelte @@ -0,0 +1,96 @@ + + + +{#if type === 'string'} + + + + +{:else if type === 'number'} + + + + +{:else if type === 'boolean'} + + + + +{:else if type === 'ling'} + + + + + +{:else if type === 'date'} + + + + +{:else if type === 'datetime'} + + + + + +{:else if type === 'url'} + + + + + +{:else if type === 'currency'} + + + + +{:else if type === 'image'} + + + + + +{:else if type === 'video'} + + + + +{:else if type === 'audio'} + + + + +{:else if type === 'pdf'} + + + + + +{:else if type === 'file'} + + + + +{:else if type === 'html'} + + + + +{:else if type === 'group'} + + + + +{/if} diff --git a/src/mtas/svelte/components/MetadataEditor.svelte b/src/mtas/svelte/components/MetadataEditor.svelte index 7863f4e..5517a21 100644 --- a/src/mtas/svelte/components/MetadataEditor.svelte +++ b/src/mtas/svelte/components/MetadataEditor.svelte @@ -2,29 +2,73 @@ import type { Metadata, MetadataField, MetadataFieldType } from '../../lib'; import { validateMetadata, field as f } from '../../lib'; import FieldRow from './FieldRow.svelte'; - import TypeBadge from './TypeBadge.svelte'; + import FieldTypeIcon from './FieldTypeIcon.svelte'; + import AddFieldDrawer from './AddFieldDrawer.svelte'; + import {makeLingRecord} from "@/ling/lib"; + import { flip } from 'svelte/animate'; let { - value = {}, + value = $bindable({}), locales = ['es'], defaultLocale = 'es', dark = false, + attributes = [], onUpdate, }: { value?: Metadata; locales?: string[]; defaultLocale?: string; dark?: boolean; + attributes?: string[]; onUpdate?: (metadata: Metadata) => void; } = $props(); - let dragSrcKey = $state(null); + let drawerOpen = $state(false); + let drawerType = $state('string'); + + // ── Pagination ─────────────────────────────────────────────────── + let page = $state(1); + let pageSize = $state(10); + const PAGE_SIZES = [5, 10, 20, 50]; + + let keyOrder = $derived( + value.order ?? Object.keys(value).filter(k => k !== 'order') + ); + + let totalPages = $derived( + pageSize === 0 ? 1 : Math.max(1, Math.ceil(keyOrder.length / pageSize)) + ); + let pageKeys = $derived( + pageSize === 0 ? keyOrder : keyOrder.slice((page - 1) * pageSize, page * pageSize) + ); + let rangeStart = $derived(pageSize === 0 ? 1 : (page - 1) * pageSize + 1); + let rangeEnd = $derived(pageSize === 0 ? keyOrder.length : Math.min(page * pageSize, keyOrder.length)); + + // Mantener page dentro de los límites + $effect(() => { if (page > totalPages) page = Math.max(1, totalPages); }); + + // Páginas a mostrar en la barra (con elipsis = -1) + let paginationPages = $derived((() => { + if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1); + const pages: number[] = [1]; + if (page > 3) pages.push(-1); + for (let p = Math.max(2, page - 1); p <= Math.min(totalPages - 1, page + 1); p++) pages.push(p); + if (page < totalPages - 2) pages.push(-1); + pages.push(totalPages); + return pages; + })()); let validation = $derived(validateMetadata(value)); - let fieldKeys = $derived(Object.keys(value)); + + const typeLabels: Record = { + string: 'Texto', number: 'Número', boolean: 'Bool', ling: 'i18n', + date: 'Fecha', datetime: 'DateTime', url: 'URL', currency: 'Moneda', + image: 'Imagen', video: 'Vídeo', audio: 'Audio', pdf: 'PDF', + file: 'Fichero', html: 'HTML', group: 'Grupo', + }; const typeGroups: { label: string; types: MetadataFieldType[] }[] = [ - { label: 'Texto', types: ['string', 'lingstring', 'url'] }, + { label: 'Texto', types: ['string', 'ling', 'html', 'url'] }, { label: 'Número', types: ['number', 'currency'] }, { label: 'Fecha', types: ['date', 'datetime'] }, { label: 'Lógico', types: ['boolean'] }, @@ -32,12 +76,14 @@ { label: 'Grupo', types: ['group'] }, ]; + function emit(): void { onUpdate?.(value); } + function defaultField(type: MetadataFieldType): MetadataField { switch (type) { case 'string': return f.string(''); case 'number': return f.number(0); case 'boolean': return f.boolean(false); - case 'lingstring': return f.lingstring({ [defaultLocale]: '' } as any); + case 'ling': return f.ling(makeLingRecord('')); case 'date': return f.date(''); case 'datetime': return f.datetime(''); case 'url': return f.url(''); @@ -47,145 +93,215 @@ case 'audio': return f.audio({ url: '' }); case 'pdf': return f.pdf({ url: '' }); case 'file': return f.file({ url: '' }); + case 'html': return f.html(makeLingRecord('')); case 'group': return f.group({}); } } - function uniqueKey(type: MetadataFieldType): string { - let n = 1; - while (`${type}_${n}` in value) n++; - return `${type}_${n}`; - } + function openDrawer(type: MetadataFieldType): void { drawerType = type; drawerOpen = true; } - function addField(type: MetadataFieldType) { - emit({ ...value, [uniqueKey(type)]: defaultField(type) }); + function handleDrawerConfirm(key: string, field: MetadataField): void { + const newOrder = [...keyOrder, key]; + value = { ...value, [key]: field, order: newOrder }; + emit(); + // Ir a la última página donde aparece el nuevo campo + if (pageSize > 0) page = Math.ceil(newOrder.length / pageSize); } - function updateFieldKey(oldKey: string, newKey: string) { + function updateFieldKey(oldKey: string, newKey: string): void { if (newKey === oldKey || newKey in value) return; - const entries = Object.entries(value).map(([k, v]) => k === oldKey ? [newKey, v] : [k, v]); - emit(Object.fromEntries(entries)); - } - - function updateField(key: string, field: MetadataField) { - emit({ ...value, [key]: field }); + const next: Metadata = {}; + for (const k of Object.keys(value)) { + if (k === 'order') continue; + next[k === oldKey ? newKey : k] = value[k]; + } + next.order = keyOrder.map(k => k === oldKey ? newKey : k); + value = next; + emit(); } - function deleteField(key: string) { - const { [key]: _, ...rest } = value; - emit(rest); + function updateField(key: string, field: MetadataField): void { + value = { ...value, [key]: field }; + emit(); } - function emit(next: Metadata) { + function deleteField(key: string): void { + const next = { ...value }; + delete next[key]; + next.order = keyOrder.filter(k => k !== key); value = next; - onUpdate?.(next); + emit(); } - function handleDrop(targetKey: string) { - if (!dragSrcKey || dragSrcKey === targetKey) return; - const entries = Object.entries(value); - const srcIdx = entries.findIndex(([k]) => k === dragSrcKey); - const tgtIdx = entries.findIndex(([k]) => k === targetKey); - if (srcIdx === -1 || tgtIdx === -1) return; - const reordered = [...entries]; - const [moved] = reordered.splice(srcIdx, 1); - reordered.splice(tgtIdx, 0, moved); - dragSrcKey = null; - emit(Object.fromEntries(reordered)); + function getField(key: string): MetadataField { return value[key] as MetadataField; } + + function moveField(key: string, dir: -1 | 1): void { + const idx = keyOrder.indexOf(key); + const newIdx = idx + dir; + if (newIdx < 0 || newIdx >= keyOrder.length) return; + const newOrder = [...keyOrder]; + newOrder.splice(idx, 1); + newOrder.splice(newIdx, 0, key); + value = { ...value, order: newOrder }; + emit(); + // Seguir al campo si cruza un límite de página + if (pageSize > 0) { + const newPage = Math.floor(newIdx / pageSize) + 1; + if (newPage !== page) page = newPage; + } } -
-
+
-
- - -
-

- Metadata -

- - {fieldKeys.length} {fieldKeys.length === 1 ? 'campo' : 'campos'} - - -
-
- {validation.valid ? 'Válido' : `${(validation as any).errors?.length} errores`} +
+
+

Metadata

+ + {keyOrder.length} {keyOrder.length === 1 ? 'campo' : 'campos'} + +
+
+ {validation.valid ? 'Válido' : `${validation.errors.length} errores`}
- -
+
{#each typeGroups as group} -
- - {group.label} - -
- {#each group.types as type} - - {/each} -
+
+ {group.label} + {#each group.types as type} + + {/each}
{/each}
- -
- {#if fieldKeys.length === 0} -
- + +
+ {#if keyOrder.length === 0} +
+ -

- Añade campos usando los botones de arriba -

+

Añade campos usando los botones de arriba

{:else} - {#each fieldKeys as key (key)} - updateFieldKey(key, newKey)} - onFieldChange={(field) => updateField(key, field)} - onDelete={() => deleteField(key)} - onDragStart={(e) => { e.dataTransfer?.setData('text/plain', key); dragSrcKey = key; }} - onDragOver={() => {}} - onDrop={() => handleDrop(key)} - /> + {#each pageKeys as key (key)} + {@const gi = keyOrder.indexOf(key)} +
+ 0} + canMoveDown={gi < keyOrder.length - 1} + onKeyChange={(newKey) => updateFieldKey(key, newKey)} + onFieldChange={(field) => updateField(key, field)} + onDelete={() => deleteField(key)} + onMoveUp={() => moveField(key, -1)} + onMoveDown={() => moveField(key, 1)} + /> +
{/each} {/if}
- - {#if !validation.valid && (validation as any).errors?.length} -
-

- Errores de validación -

-
    - {#each (validation as any).errors as error} -
  • · {error}
  • - {/each} -
+ {#if !validation.valid} +
+
+
+ Errores de validación + {#each validation.errors as error} + · {error} + {/each} +
+
+
+ {/if} + + + {#if keyOrder.length > 0} +
+ + +
+ Filas por página + +
+ + + {#if totalPages > 1} +
+ + + {#each paginationPages as p (p === -1 ? `ellipsis-${p}` : p)} + {#if p === -1} + + {:else} + + {/if} + {/each} + + +
+ {/if} + + + + {keyOrder.length === 0 ? '0' : rangeStart}–{rangeEnd} de {keyOrder.length} + +
{/if} -
-
\ No newline at end of file + +
+ + drawerOpen = false} +/> diff --git a/src/mtas/svelte/components/TypeBadge.svelte b/src/mtas/svelte/components/TypeBadge.svelte index f09ca56..c02c0fc 100644 --- a/src/mtas/svelte/components/TypeBadge.svelte +++ b/src/mtas/svelte/components/TypeBadge.svelte @@ -1,32 +1,39 @@ - - {cfg.label} - + + + {#if showLabel} + {cfg.label} + {/if} + \ No newline at end of file diff --git a/src/mtas/svelte/demo/App.svelte b/src/mtas/svelte/demo/App.svelte index 90bafd3..87db8b7 100644 --- a/src/mtas/svelte/demo/App.svelte +++ b/src/mtas/svelte/demo/App.svelte @@ -5,12 +5,15 @@ import type { Metadata } from '../../lib'; import type { LingRecord } from '@/ling/lib'; + + // ── Demo metadata con todos los tipos ─────────────────────────── let metadata = $state({ + order: ['nombre','año','disponible','descripcion','lanzamiento','ultima_mod','web','precio','foto','manual','specs'], nombre: { type: 'string', value: 'Toyota Corolla' }, año: { type: 'number', value: 2024 }, disponible: { type: 'boolean', value: true }, - descripcion: { type: 'lingstring', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } }, + descripcion: { type: 'ling', value: { es: 'Sedán compacto fiable', en: 'Reliable compact sedan' } as LingRecord}, lanzamiento: { type: 'date', value: '2024-01-15' }, ultima_mod: { type: 'datetime', value: '2024-03-09T10:00:00Z' }, web: { type: 'url', value: 'https://toyota.es' }, @@ -18,10 +21,10 @@ foto: { type: 'image', value: { url: 'https://images.unsplash.com/photo-1621007947382-bb3c3994e3fb?w=400&h=300&fit=crop', mimeType: 'image/jpeg' } }, manual: { type: 'pdf', value: { url: 'https://example.com/manual.pdf', filename: 'manual.pdf' } }, specs: { type: 'group', value: { - motor: { type: 'string', value: '1.8 VVT-i' }, - cv: { type: 'number', value: 122 }, - traccion: { type: 'string', value: 'FWD' }, - }, order: ['cv', 'motor', 'traccion'] }, + motor: { type: 'string', value: '1.8 VVT-i' }, + cv: { type: 'number', value: 122 }, + traccion: { type: 'string', value: 'FWD' }, + }, order: ['cv', 'motor', 'traccion'] }, }); // ── LingInput demo ─────────────────────────────────────────────── @@ -39,18 +42,16 @@ meta-content · Demo - +
+ style="font-family: 'Poppins', sans-serif;"> -
@@ -76,8 +77,8 @@
{:else} -
+
@@ -148,14 +149,15 @@ MetadataEditor -
+
metadata = m} + bind:value={metadata} + {locales} + attributes={['hidden','required']} + {defaultLocale} + dark={darkMode} + onUpdate={(m) => metadata = m} />
@@ -176,34 +178,34 @@ lingValue = v} + value={lingValue} + {locales} + {defaultLocale} + label="Descripción" + placeholder="Escribe aquí…" + required={true} + invalid={lingInvalid} + as="textarea" + rows={3} + onUpdate={(v) => lingValue = v} /> lingValue = v} + value={lingValue} + {locales} + {defaultLocale} + label="Título" + as="input" + onUpdate={(v) => lingValue = v} />
Simular inválido - {/each} -
-

- {#if attr.type === 'required'}El usuario debe elegir. null = configuración incompleta. - {:else if attr.type === 'optional'}El usuario activa o desactiva. - {:else}Valor constante. Puede ser fuente de constraints.{/if} -

- - - -
-

Comportamiento

- {#each ([ - ['uiVisible', 'Visible en UI', '— Mostrar en el configurador'], - ['affectsVisual', 'Afecta a la imagen', '— Cambiar esta opción actualiza el render'], - ['affectsPrice', 'Afecta al precio', '— Cambiar esta opción recalcula el precio'], - ] as const) as [key, label, hint]} - - {/each} -
- - - {#if hasOptions(attr)} - {@const attrWithOpts = attr as RequiredAttribute} -
-

- Opciones ({attrWithOpts.options.length}) -

- - {#if attrWithOpts.options.length === 0} -

Sin opciones — añade desde la librería

- {:else} -
- {#each attrWithOpts.options as attrOpt (attrOpt.optionId)} - {@const price = optPrice(attrOpt.optionId)} -
-
- {optName(attrOpt.optionId)} - - {editor.collection.options[attrOpt.optionId]?.code ?? ''} - -
-
- {#if price} - {price} - {/if} - -
-
- {/each} -
- {/if} - - -
- - - {#if optPickerOpen} - -
-
- -
-
    - {#if filteredPicker.length === 0} -
  • - {allOptions.length === 0 ? 'No hay opciones — créalas en el panel Opciones' : 'Sin resultados'} -
  • - {:else} - {#each filteredPicker as opt (opt.id)} -
  • - -
  • - {/each} - {/if} -
-
- {/if} -
- - - {#if attrWithOpts.options.length > 0} -
- - -
- {/if} -
- {/if} - - - {#if attr.type === 'fixed'} - {@const fixedAttr = attr as FixedAttribute} -
-

Valor fijo

- -

- El valor constante no puede ser modificado por el usuario. -

-
- {/if} - - - {#if entity} -
-

- Reglas - - ({((attr as any).rules ?? []).length}) - -

- -
- {:else} -
-

Reglas

-

- Los atributos de catálogo no tienen reglas — son el nivel raíz. -

-
- {/if} - -
\ No newline at end of file diff --git a/src/vice/editor/comps/AttributeList.svelte b/src/vice/editor/comps/AttributeList.svelte deleted file mode 100644 index 73b47fa..0000000 --- a/src/vice/editor/comps/AttributeList.svelte +++ /dev/null @@ -1,141 +0,0 @@ - - -
- - -
- - -
- -
- - -
- - Atributos ({group.attributes.length}) - - -
- -
- {#if group.attributes.length === 0} -
-

Sin atributos

-

Suelo, color, material...

- -
- {:else} - {#each group.attributes as attr (attr.id)} - {@const isActive = editor.activeAttrId === attr.id} - {@const hasError = editor.validation.errors.some(e => e.path.includes(attr.id))} - {@const hasWarn = editor.validation.warnings.some(e => e.path.includes(attr.id))} - {@const name = ling(attr.name as Record, langs[0])} -
editor.setActiveAttribute(attr.id)} - onkeydown={(e) => e.key === 'Enter' && editor.setActiveAttribute(attr.id)} - class=" - w-full text-left 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'} - " - > - - {typeLabel[attr.type] ?? attr.type} - - - {#if hasError} - - {:else if hasWarn} - - {/if} - - - {#if name}{name}{:else}Sin nombre{/if} - - - {#if attr.type === 'required' || attr.type === 'optional'} - - {attr.options?.length ?? 0} - - {/if} - - -
- {/each} - {/if} -
-
- - -
- {#if editor.activeAttribute} - - {:else} -
- Selecciona un atributo para editarlo -
- {/if} -
- -
\ No newline at end of file diff --git a/src/vice/editor/comps/CollectionPanel.svelte b/src/vice/editor/comps/CollectionPanel.svelte deleted file mode 100644 index ca68c2a..0000000 --- a/src/vice/editor/comps/CollectionPanel.svelte +++ /dev/null @@ -1,339 +0,0 @@ - - -
- - -
-

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

-
- - -
-
-

- Atributos de contexto - ({col.attributes.length}) -

- -
- - {#if col.attributes.length === 0} -

- Sin atributos — añade variables de contexto como país, normativa fiscal, año... -

- {:else} -
- {#each col.attributes as attr} - {@const lit = asLiteral(attr)} -
-
- - editor.updateCatalogAttribute(attr.id, { code: e.currentTarget.value })} - placeholder="country" - class="field-input font-mono text-xs w-32" /> - - - - - - {#if lit.kind === 'boolean'} - - {:else} - editor.updateCatalogAttribute(attr.id, { - value: lit.kind === 'number' - ? Number(e.currentTarget.value) - : e.currentTarget.value - })} - class="field-input text-xs w-24" - /> - {/if} - - -
- - -
- {#each col.langs as lang} -
- {lang} - patchLiteralName(attr, lang, e.currentTarget.value)} - placeholder={lang === 'es' ? 'País' : ''} - class="field-input text-xs flex-1" /> -
- {/each} -
-
- {/each} -
- {/if} -
- - -
-

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/ConditionBuilder.svelte b/src/vice/editor/comps/ConditionBuilder.svelte deleted file mode 100644 index 4e7423d..0000000 --- a/src/vice/editor/comps/ConditionBuilder.svelte +++ /dev/null @@ -1,431 +0,0 @@ - - -
- - -
- Condición -
- {#if !rawMode && activeClauseCount === 0} - siempre activa - {/if} - {#if parseFailed} - JsonLogic complejo - {:else} - - {/if} -
-
- - {#if rawMode || parseFailed} - - - -

- JsonLogic — variables son AttrIDs. Vacío = siempre activa. -

- - {:else} - - - - - {#if draft.clauses.length > 1} -
- Combinar con - {#each (['and', 'or'] as const) as l} - - {/each} -
- {/if} - - - {#each draft.clauses as clause, i (i)} - {@const kind = getAttrKind(clause.attrId)} - {@const ops = OPS[kind]} - {@const options = getAttrOptions(clause.attrId)} - {@const multi = clause.op === 'in' || clause.op === '!in'} - -
- - -
- - - - - - {#if clause.attrId && kind !== 'boolean' && kind !== 'unknown' && ops.length > 0} - - {/if} - - - {#if clause.attrId && kind === 'boolean'} - - {/if} - - - {#if clause.attrId && kind === 'number'} - patchClause(i, { numValue: Number(e.currentTarget.value) })} - class="w-20 shrink-0 text-xs rounded border border-gray-700 bg-gray-800 - text-gray-300 px-1.5 py-1" - /> - {/if} - - - {#if clause.attrId && kind === 'string'} - patchClause(i, { strValue: e.currentTarget.value })} - placeholder="valor..." - class="w-28 shrink-0 text-xs rounded border border-gray-700 bg-gray-800 - text-gray-300 px-1.5 py-1" - /> - {/if} - - - -
- - - {#if clause.attrId && kind === 'options'} -
- {#if options.length === 0} - Sin opciones en este atributo - {:else} - {#each options as opt (opt.id)} - {@const active = clause.optValues.includes(opt.id)} - - {/each} - {/if} -
- {/if} - -
- {/each} - - - - - {/if} - -
diff --git a/src/vice/editor/comps/EntitiesPanel.svelte b/src/vice/editor/comps/EntitiesPanel.svelte deleted file mode 100644 index cea9847..0000000 --- a/src/vice/editor/comps/EntitiesPanel.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - -
- - -
- -
- - - {#if editor.activeEntity} -
- -
- {/if} - - -
- {#if editor.activeGroup} - - {:else if editor.activeAttrId && editor.activeEntity} - -
- -
- {:else if editor.activeEntity} -
- Selecciona un grupo o un atributo de entidad -
- {:else} -
- Selecciona o crea una entidad -
- {/if} -
- -
\ No newline at end of file diff --git a/src/vice/editor/comps/EntityAttributeList.svelte b/src/vice/editor/comps/EntityAttributeList.svelte deleted file mode 100644 index 51623b1..0000000 --- a/src/vice/editor/comps/EntityAttributeList.svelte +++ /dev/null @@ -1,160 +0,0 @@ - - -
- - -
-
- - {#each sections as section (section.key)} - - -
- - {section.label} - ({section.attrs.length}) - - -
- - - {#if section.attrs.length === 0} -
- Sin atributos -
- {:else} - {#each section.attrs as attr (attr.id)} - {@const isActive = editor.activeAttrId === attr.id} - {@const hasError = editor.validation.errors.some(e => e.path.includes(attr.id))} - {@const hasWarn = editor.validation.warnings.some(e => e.path.includes(attr.id))} - {@const name = ling(attr.name as Record, langs[0])} -
editor.setActiveAttribute(attr.id)} - onkeydown={(e) => e.key === 'Enter' && editor.setActiveAttribute(attr.id)} - class=" - w-full text-left 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'} - " - > - - {typeLabel[attr.type] ?? attr.type} - - - {#if hasError} - - {:else if hasWarn} - - {/if} - - - {#if name}{name}{:else}Sin nombre{/if} - - - {#if attr.type === 'required' || attr.type === 'optional'} - - {(attr as any).options?.length ?? 0} - - {/if} - - -
- {/each} - {/if} - - {/each} -
-
- - -
- {#if editor.activeAttribute} - - {:else} -
- Selecciona un atributo para editarlo -
- {/if} -
- -
\ No newline at end of file diff --git a/src/vice/editor/comps/EntityList.svelte b/src/vice/editor/comps/EntityList.svelte deleted file mode 100644 index 232097e..0000000 --- a/src/vice/editor/comps/EntityList.svelte +++ /dev/null @@ -1,89 +0,0 @@ - - - -
- - Entidades ({entities.length}) - - -
- -
- {#if entities.length === 0} -
-

Sin entidades

-

- Una entidad es un producto configurable — apartamento, coche, habitación... -

- -
- {:else} -
    - {#each entities as entity (entity.id)} -
  • -
    editor.setActiveEntity(entity.id)} - onkeydown={(e) => e.key === 'Enter' && editor.setActiveEntity(entity.id)} - class=" - w-full text-left px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer - transition-colors group - {editor.activeEntityId === entity.id - ? 'bg-indigo-600/20 text-indigo-300' - : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'} - " - > - - {#if editor.validation.errors.some(e => e.path.startsWith(`entities.${entity.id}`))} - - {:else if editor.validation.warnings.some(e => e.path.startsWith(`entities.${entity.id}`))} - - {:else} - - {/if} - - - {#if entity.name.es} - {entity.name.es} - {:else} - Sin nombre - {/if} - - - - - {Object.keys(entity.groups).length}g - - - - -
    -
  • - {/each} -
- {/if} -
diff --git a/src/vice/editor/comps/EntityMetaForm.svelte b/src/vice/editor/comps/EntityMetaForm.svelte deleted file mode 100644 index 16c83af..0000000 --- a/src/vice/editor/comps/EntityMetaForm.svelte +++ /dev/null @@ -1,64 +0,0 @@ - - -
- - -
- - patch('code', e.currentTarget.value)} - placeholder="apartamento" - class="field-input font-mono text-xs {!entity.code ? 'border-red-800' : ''}" - /> -
- - - {#each langs as lang (lang)} -
- - patchI18n('name', lang, e.currentTarget.value)} - placeholder={lang === 'es' ? 'Apartamento' : 'Apartment'} - class="field-input {lang === 'es' && !entity.name.es ? 'border-red-800' : ''}" - /> -
- {/each} - -
diff --git a/src/vice/editor/comps/GroupList.svelte b/src/vice/editor/comps/GroupList.svelte deleted file mode 100644 index fdf119c..0000000 --- a/src/vice/editor/comps/GroupList.svelte +++ /dev/null @@ -1,187 +0,0 @@ - - - -
- - - {#if showMeta} -
- -
- {/if} -
- -
- - -
- - Grupos ({groups.length}) - -
- - -
-
- - {#if groups.length === 0} -
- Sin grupos - -
- {:else} -
    - {#each groups as group (group.id)} -
  • -
    editor.setActiveGroup(group.id)} - onkeydown={(e) => e.key === 'Enter' && editor.setActiveGroup(group.id)} - class=" - w-full text-left px-4 py-2.5 flex items-center gap-2 text-sm cursor-pointer - transition-colors group - {editor.activeGroupId === group.id - ? 'bg-indigo-600/20 text-indigo-300' - : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'} - " - > - - {group.kind === 'fixed' ? '◉' : '◈'} - - - {#if editor.validation.errors.some(e => e.path.includes(group.id))} - - {:else if editor.validation.warnings.some(e => e.path.includes(group.id))} - - {/if} - - - {#if ling(group.name as Record)} - {ling(group.name as Record)} - {:else} - Sin nombre - {/if} - - - {group.attributes.length} - - -
    -
  • - {/each} -
- {/if} - - -
- - Attrs entidad ({entity.attributes.length}) - - -
- - {#if entity.attributes.length === 0} -
- Sin atributos de entidad -
- {:else} - {#each entity.attributes as attr (attr.id)} - {@const isActive = editor.activeAttrId === attr.id} - {@const name = ling(attr.name as Record)} -
editor.setActiveEntityAttribute(attr.id)} - onkeydown={(e) => e.key === 'Enter' && editor.setActiveEntityAttribute(attr.id)} - class=" - w-full text-left px-4 py-2 flex items-center gap-2 text-sm cursor-pointer - transition-colors group border-l-2 - {isActive - ? 'bg-amber-600/20 text-amber-300 border-amber-500' - : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200 border-transparent'} - " - > - - {typeLabel[attr.type] ?? attr.type} - - - {#if name}{name}{:else}Sin nombre{/if} - - -
- {/each} - {/if} - -
\ No newline at end of file diff --git a/src/vice/editor/comps/GroupMetaForm.svelte b/src/vice/editor/comps/GroupMetaForm.svelte deleted file mode 100644 index 282e02d..0000000 --- a/src/vice/editor/comps/GroupMetaForm.svelte +++ /dev/null @@ -1,79 +0,0 @@ - - -
- -
- -
- - patch('code', e.currentTarget.value)} - placeholder="salon" - class="field-input font-mono text-xs {!group.code ? 'border-red-800' : ''}" - /> -
- - -
- - -
-
- - - {#each langs as lang (lang)} -
- - patchI18n('name', lang, e.currentTarget.value)} - placeholder={lang === 'es' ? 'Salón' : 'Living room'} - class="field-input {lang === 'es' && !group.name.es ? 'border-red-800' : ''}" - /> -
- {/each} - -
diff --git a/src/vice/editor/comps/OptionDetail.svelte b/src/vice/editor/comps/OptionDetail.svelte deleted file mode 100644 index 1ebcc7a..0000000 --- a/src/vice/editor/comps/OptionDetail.svelte +++ /dev/null @@ -1,219 +0,0 @@ - - -
- - -
-

{ling.ts(opt.name) || 'Opción sin nombre'}

-
- - {opt.id} -
-
- - -
-

Identificación

- - -
- - patch('code', e.currentTarget.value)} - placeholder="parquet_roble" - class="field-input font-mono text-xs" /> -
- - -
- -
- {#each langs as lang} -
- {lang} - patchI18n('name', lang, e.currentTarget.value)} - class="field-input flex-1" /> -
- {/each} -
-
- - -
- -
- {#each langs as lang} -
- {lang} - -
- {/each} -
-
-
- - -
-

Precio

- -
- {#each (['fixed', 'dynamic'] as const) as type} - - {/each} -
- -
- -
- setBaseAmount(Number(e.currentTarget.value))} - min="0" step="0.01" class="field-input pr-8" /> - € -
-
- - {#if pricingType === 'dynamic'} -
- El precio dinámico usa una expresión JsonLogic. Edítala en el JSON exportado. -
- {/if} -
- - -
-

Tags

-
- {#each (opt.tags ?? []) as tag (tag)} - - {tag} - - - {:else} - Sin tags - {/each} -
-
- - -
-
- - - {#if usages.length > 0} -
-

- Usada en ({usages.length}) -

-
- {#each usages as use} -
- {use.entityName} - › - {use.groupName} - › - {use.attrName} -
- {/each} -
-
- {/if} - -
\ No newline at end of file diff --git a/src/vice/editor/comps/OptionsPanel.svelte b/src/vice/editor/comps/OptionsPanel.svelte deleted file mode 100644 index 50154a3..0000000 --- a/src/vice/editor/comps/OptionsPanel.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - -
- - -
- - -
- - Opciones ({options.length}) - - -
- - -
- -
- - -
- {#if options.length === 0} -
-

Sin opciones

-

Parquet, mármol, lacado blanco...

-
- {:else if filtered.length === 0} -
Sin resultados
- {:else} - {#each [...grouped] as [tag, opts]} -
- {tag} -
- {#each opts as opt (opt.id)} -
editor.setActiveOption(opt.id)} - onkeydown={(e) => e.key === 'Enter' && editor.setActiveOption(opt.id)} - class="px-3 py-2 flex items-center gap-2 text-sm cursor-pointer transition-colors group - {editor.activeOptionId === opt.id - ? 'bg-indigo-600/20 text-indigo-300' - : 'text-gray-400 hover:bg-gray-800 hover:text-gray-200'}" - > - {#if editor.validation.errors.some(e => e.path.startsWith(`options.${opt.id}`))} - - {:else if editor.validation.warnings.some(e => e.path.startsWith(`options.${opt.id}`))} - - {:else} - - {/if} - - {ling.ts(opt.name) || 'Sin nombre'} - - {#if opt.pricing && !opt.pricing.dynamicExpression} - {opt.pricing.baseAmount}€ - {/if} - - -
- {/each} - {/each} - {/if} -
-
- - -
- {#if editor.activeOption} - - {:else} -
-

Selecciona o crea una opción

-

- Las opciones son los valores seleccionables — parquet, mármol, lacado blanco... - Se definen una vez y se reutilizan en múltiples atributos. -

- -
- {/if} -
- -
\ No newline at end of file diff --git a/src/vice/editor/comps/RuleEditor.svelte b/src/vice/editor/comps/RuleEditor.svelte deleted file mode 100644 index 0bbb71c..0000000 --- a/src/vice/editor/comps/RuleEditor.svelte +++ /dev/null @@ -1,240 +0,0 @@ - - -
- - -
- - - - - - - - - {#if groupActions.includes(rule.action.type)} - - {:else} - - {/if} - - - -
- - - {#if needsValues && targetAttrOptions.length > 0} -
- {#each targetAttrOptions as opt (opt.id)} - {@const active = (rule.action.values ?? []).includes(opt.id)} - - {/each} - {#if needsValues && targetAttrOptions.length === 0} - Selecciona un atributo target primero - {/if} -
- {/if} - - -
- onchange({ condition: jl })} - /> -
- - -
- - {#each langs as lang (lang)} - )?.[lang] ?? ''} - oninput={(e) => patchMessage(lang, e.currentTarget.value)} - placeholder="Esta opción requiere..." - class="field-input text-xs" - /> - {/each} -
- -
\ No newline at end of file diff --git a/src/vice/editor/comps/RuleList.svelte b/src/vice/editor/comps/RuleList.svelte deleted file mode 100644 index 5e89cd6..0000000 --- a/src/vice/editor/comps/RuleList.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -
- - {#if rules.length === 0} -

Sin reglas

- {:else} - {#each rules as rule (rule.id)} - updateRule(rule.id!, patch)} - ondelete={() => deleteRule(rule.id!)} - /> - {/each} - {/if} - - - -
diff --git a/src/vice/editor/comps/Sidebar.svelte b/src/vice/editor/comps/Sidebar.svelte deleted file mode 100644 index c41a76f..0000000 --- a/src/vice/editor/comps/Sidebar.svelte +++ /dev/null @@ -1,35 +0,0 @@ - - - diff --git a/src/vice/editor/comps/TopBar.svelte b/src/vice/editor/comps/TopBar.svelte deleted file mode 100644 index 69faeaf..0000000 --- a/src/vice/editor/comps/TopBar.svelte +++ /dev/null @@ -1,74 +0,0 @@ - - -
- - -
- 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 deleted file mode 100644 index 4479317..0000000 --- a/src/vice/editor/comps/ViceEditor.svelte +++ /dev/null @@ -1,191 +0,0 @@ - - - - - \ No newline at end of file diff --git a/src/vice/editor/layouts/EditorLayout.svelte b/src/vice/editor/layouts/EditorLayout.svelte deleted file mode 100644 index c55b2e9..0000000 --- a/src/vice/editor/layouts/EditorLayout.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -
- - - -
- - - -
- {#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.tsa b/src/vice/editor/stores/EditorStore.svelte.tsa deleted file mode 100644 index dd188ac..0000000 --- a/src/vice/editor/stores/EditorStore.svelte.tsa +++ /dev/null @@ -1,606 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// 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 { - Attribute, - AttributeOption, - AttrID, - Collection, - CollectionID, - Entity, - EntityID, - FixedGroup, - Group, - GroupID, - OptionalGroup, - OptionDefinition, - OptionID, - RequiredAttribute, -} from '@/vice/libs/types' -import type {LingString} from "@/ling"; - -// ───────────────────────────────────────────────────────────────────────────── -// 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: '', - 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 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) - - // ── Derived ──────────────────────────────────────────────────────────────── - - const validation = $derived(validateCollection(collection)) - 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 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 } - } - - // ── Options library ──────────────────────────────────────────────────────── - - function addOption(): OptionID { - const id = uid('op') as OptionID - collection = { - ...collection, - options: { ...collection.options, [id]: emptyOption(id) }, - } - activeOptionId = id - return id - } - - function updateOption(id: OptionID, patch: Partial) { - const opt = collection.options[id] - if (!opt) return - collection = { - ...collection, - options: { ...collection.options, [id]: { ...opt, ...patch } }, - } - } - - function deleteOption(id: OptionID) { - const { [id]: _, ...rest } = collection.options - collection = { ...collection, options: rest as Record } - if (activeOptionId === id) activeOptionId = null - } - - 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 - 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 }, - } - } - - function deleteEntity(id: EntityID) { - const { [id]: _, ...rest } = collection.entities - collection = { ...collection, entities: rest as Record } - if (activeEntityId === id) setActiveEntity(null) - } - - // ── 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 - 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 }, - }, - }, - } - } - - 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 } - } - - // ── 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) - } - - function deleteAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID) { - collection = produceAttr(collection, entityId, groupId, attrId, null) - if (activeAttrId === attrId) activeAttrId = null - } - - // ── 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] - // Si es la primera opción, ponerla como default - const updated = { ...attr, options } - if (!attr.default && options.length === 1) (updated as any).default = optionId - return updated - }) - } - - 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 - }) - } - - // ── 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) { - collection = JSON.parse(json) - activeEntityId = null - activeGroupId = null - activeAttrId = null - activeOptionId = null - } - - function reset() { - collection = emptyCollection() - activeEntityId = null - activeGroupId = null - activeAttrId = null - activeOptionId = null - } - - // ── Public API ───────────────────────────────────────────────────────────── - - return { - // State (getters) - get collection() { return collection }, - get validation() { return validation }, - 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 - 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 diff --git a/src/vice/editor/stores/editor.svelte.ts b/src/vice/editor/stores/editor.svelte.ts deleted file mode 100644 index f19f9d2..0000000 --- a/src/vice/editor/stores/editor.svelte.ts +++ /dev/null @@ -1,812 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// 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 { - Collection, CollectionID, - Entity, EntityID, - Group, GroupID, - Attribute, AttrID, - AttributeOption, - OptionDefinition, OptionID, - Rule, - EntityPath, - EntityPathKey, - LiteralValue, -} from '@/vice/libs/types' -import type {FixedGroup, OptionalGroup} from "@/vice/libs"; -import type {LingString} from "@/ling/lib"; - -// ───────────────────────────────────────────────────────────────────────────── -// 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: '', - name: emptyLing(), - description: emptyLing(), - langs: ['es'], - 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): import('@/vice/libs/types.ts').RequiredAttribute { - return { - id, - code: '', - name: emptyLing(), - description: emptyLing(), - type: 'required', - domain: { kind: 'options', options: [], default: '' as OptionID }, - rules: [], - display: {}, - } -} - -function emptyFixedAttribute(id: AttrID): import('@/vice/libs/types.ts').FixedAttribute { - return { - id, - code: '', - name: emptyLing(), - description: emptyLing(), - type: 'fixed', - domain: { kind: 'fixed', value: { kind: 'string', value: '' } }, - 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' }) - - // Validar nombre en todos los langs declarados - const name = collection.name as any - for (const lang of collection.langs) { - if (!name?.[lang]) { - errors.push({ severity: 'error', message: `El catálogo necesita nombre en "${lang}"`, path: `collection.name.${lang}` }) - } - } - - // 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` }) - - const optName = opt.name as any - for (const lang of collection.langs) { - if (!optName?.[lang]) { - warnings.push({ severity: 'warning', message: `Opción "${opt.id}" sin nombre en "${lang}"`, path: `options.${opt.id}.name.${lang}` }) - } - } - } - - // 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` }) - - const entityName = entity.name as any - for (const lang of collection.langs) { - if (!entityName?.[lang]) { - warnings.push({ severity: 'warning', message: `Entidad "${entity.id}" sin nombre en "${lang}"`, path: `entities.${entity.id}.name.${lang}` }) - } - } - - // 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` }) - - const groupName = group.name as any - for (const lang of collection.langs) { - if (!groupName?.[lang]) { - warnings.push({ severity: 'warning', message: `Grupo "${groupId}" sin nombre en "${lang}"`, path: `entities.${entity.id}.groups.${groupId}.name.${lang}` }) - } - } - - // 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` }) - - const attrName = attr.name as any - for (const lang of collection.langs) { - if (!attrName?.[lang]) { - warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin nombre en "${lang}"`, path: `${path}.name.${lang}` }) - } - } - - if (attr.type !== 'fixed') { - const domain = attr.domain - if (domain.kind === 'options') { - if (domain.options.length === 0) { - warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin opciones`, path: `${path}.domain.options` }) - } else { - const hasDefault = domain.options.some(o => o.optionId === domain.default) - if (!hasDefault) { - errors.push({ severity: 'error', message: `Atributo "${attr.id}": el valor por defecto no está en las opciones`, path: `${path}.domain.default` }) - } - for (const attrOpt of domain.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}.domain.options.${attrOpt.optionId}` }) - } - } - } - } else if (domain.kind === 'literals') { - if (domain.literals.length === 0) { - warnings.push({ severity: 'warning', message: `Atributo "${attr.id}" sin literales en el dominio`, path: `${path}.domain.literals` }) - } - } - } -} - -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(validateCollection(collection)) - 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 setActiveEntityAttribute(id: AttrID | null) { - activeGroupId = 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) { - 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 - markDirty() - 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' || attr.type === 'literal') 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' || attr.type === 'literal' ) 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 = emptyLiteralAttribute(id) - collection = { ...collection, attributes: [...collection.attributes, attr] } - activeAttrId = id - activeEntityId = null - activeGroupId = null - markDirty() - 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 - } - - // ── Rules ────────────────────────────────────────────────────────────────── - - function addRule( - entityId: EntityID, - groupId: GroupID | undefined, - attrId: AttrID | undefined, - rule: Rule, - ) { - if (attrId) { - collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({ - ...a, - rules: [...((a as any).rules ?? []), rule], - })) - } else if (groupId) { - collection = produceGroup(collection, entityId, groupId, (g) => ({ - ...g, - rules: [...((g as any).rules ?? []), rule], - })) - } else { - collection = produceEntity(collection, entityId, (e) => ({ - ...e, - rules: [...(e.rules ?? []), rule], - })) - } - isDirty = true - } - - function updateRule( - entityId: EntityID, - groupId: GroupID | undefined, - attrId: AttrID | undefined, - ruleId: string, - patch: Partial, - ) { - const mapRules = (rules: Rule[]) => - rules.map(r => (r.id === ruleId ? { ...r, ...patch } : r)) - - if (attrId) { - collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({ - ...a, - rules: mapRules((a as any).rules ?? []), - })) - } else if (groupId) { - collection = produceGroup(collection, entityId, groupId, (g) => ({ - ...g, - rules: mapRules((g as any).rules ?? []), - })) - } else { - collection = produceEntity(collection, entityId, (e) => ({ - ...e, - rules: mapRules(e.rules ?? []), - })) - } - isDirty = true - } - - function deleteRule( - entityId: EntityID, - groupId: GroupID | undefined, - attrId: AttrID | undefined, - ruleId: string, - ) { - const filterRules = (rules: Rule[]) => - rules.filter(r => r.id !== ruleId) - - if (attrId) { - collection = produceAttr(collection, entityId, groupId, attrId, (a) => ({ - ...a, - rules: filterRules((a as any).rules ?? []), - })) - } else if (groupId) { - collection = produceGroup(collection, entityId, groupId, (g) => ({ - ...g, - rules: filterRules((g as any).rules ?? []), - })) - } else { - collection = produceEntity(collection, entityId, (e) => ({ - ...e, - rules: filterRules(e.rules ?? []), - })) - } - isDirty = true - } - - // ── 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, - setActiveEntityAttribute, - 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, - - // Rules - addRule, - updateRule, - deleteRule, - - // 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) }, - }, - } -} - -function produceEntity( - collection: Collection, - entityId: EntityID, - fn: (entity: Entity) => Entity, -): Collection { - const entity = collection.entities[entityId] - if (!entity) return collection - return { - ...collection, - entities: { ...collection.entities, [entityId]: fn(entity) }, - } -} - -function produceGroup( - collection: Collection, - entityId: EntityID, - groupId: GroupID, - fn: (group: Group) => Group, -): Collection { - const entity = collection.entities[entityId] - if (!entity) return collection - const group = entity.groups[groupId] - if (!group) return collection - return { - ...collection, - entities: { - ...collection.entities, - [entityId]: { - ...entity, - groups: { ...entity.groups, [groupId]: fn(group) }, - }, - }, - } -} \ No newline at end of file diff --git a/src/vice/libs/engine.ts b/src/vice/libs/engine.ts deleted file mode 100644 index a412634..0000000 --- a/src/vice/libs/engine.ts +++ /dev/null @@ -1,121 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// VICE · Engine Implementation -// -// Implementación de ViceEngine. -// Junta GacGraphBuilder, GacValidator, GacEngine y Session. -// ───────────────────────────────────────────────────────────────────────────── - -import type { Collection, EntityID } from './types.ts' -import type { ViceEngine, ViceSession, LoadResult } from './engine_types.ts' -import { GacGraphBuilder } from './graph_builder.ts' -import { GacValidator } from './validator.ts' -import { GacEngine } from './gac.ts' -import { Session } from './session.ts' -import type { GacGraph } from './gac.ts' -import type {JsonLogic} from "@/tsat/lib/types"; - -// ───────────────────────────────────────────────────────────────────────────── -// JSON LOGIC EVALUATOR -// Implementación mínima — reemplazable por json-logic-js en producción. -// ───────────────────────────────────────────────────────────────────────────── - -function evalLogic(logic: JsonLogic, data: Record): unknown { - if (logic === null || typeof logic !== 'object') return logic - if (Array.isArray(logic)) return logic - - const obj = logic as Record - - if ('var' in obj) { - const key = obj['var'] as string - return key in data ? data[key] : null - } - if ('==' in obj) { - const [a, b] = obj['=='] as [JsonLogic, JsonLogic] - return evalLogic(a, data) === evalLogic(b, data) - } - if ('!=' in obj) { - const [a, b] = obj['!='] as [JsonLogic, JsonLogic] - return evalLogic(a, data) !== evalLogic(b, data) - } - if ('>' in obj) { - const [a, b] = obj['>'] as [JsonLogic, JsonLogic] - return (evalLogic(a, data) as number) > (evalLogic(b, data) as number) - } - if ('>=' in obj) { - const [a, b] = obj['>='] as [JsonLogic, JsonLogic] - return (evalLogic(a, data) as number) >= (evalLogic(b, data) as number) - } - if ('<' in obj) { - const [a, b] = obj['<'] as [JsonLogic, JsonLogic] - return (evalLogic(a, data) as number) < (evalLogic(b, data) as number) - } - if ('<=' in obj) { - const [a, b] = obj['<='] as [JsonLogic, JsonLogic] - return (evalLogic(a, data) as number) <= (evalLogic(b, data) as number) - } - if ('and' in obj) { - return (obj['and'] as JsonLogic[]).every(c => Boolean(evalLogic(c, data))) - } - if ('or' in obj) { - return (obj['or'] as JsonLogic[]).some(c => Boolean(evalLogic(c, data))) - } - if ('!' in obj) { - return !evalLogic(obj['!'] as JsonLogic, data) - } - if ('in' in obj) { - const [val, arr] = obj['in'] as [JsonLogic, JsonLogic] - const resolved = evalLogic(arr, data) - return Array.isArray(resolved) && resolved.includes(evalLogic(val, data)) - } - - return false -} - -// ───────────────────────────────────────────────────────────────────────────── -// ENGINE IMPL -// ───────────────────────────────────────────────────────────────────────────── - -export class ViceEngineImpl implements ViceEngine { - - private gacEngine = new GacEngine(evalLogic) - private graph: GacGraph | null = null - private result: LoadResult | null = null - - load(collection: Collection, entityId: EntityID): LoadResult { - const graph = new GacGraphBuilder().build(collection, entityId) - const validator = new GacValidator() - const validation = validator.validate(graph) - - this.graph = graph - this.result = { - valid: validation.valid, - issues: validation.issues, - entityId, - } - - return this.result - } - - createSession(): ViceSession { - if (!this.graph) throw new Error('[VICE] No graph loaded — call load() first') - if (!this.result) throw new Error('[VICE] No result — call load() first') - if (!this.result.valid) { - const errors = this.result.issues - .filter(i => i.severity === 'error') - .map(i => i.message) - .join('\n') - throw new Error(`[VICE] Cannot create session — graph has errors:\n${errors}`) - } - - return new Session(this.graph, this.gacEngine) - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// FACTORY -// ───────────────────────────────────────────────────────────────────────────── - -export function createViceEngine(): ViceEngine { - return new ViceEngineImpl() -} - diff --git a/src/vice/libs/engine_types.ts b/src/vice/libs/engine_types.ts deleted file mode 100644 index 872fcb9..0000000 --- a/src/vice/libs/engine_types.ts +++ /dev/null @@ -1,27 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// VICE · Engine — API pública -// ───────────────────────────────────────────────────────────────────────────── - -import type { Collection, EntityID, OptionID, EntityPathKey } from './types.ts' -import type { ValidationResult } from './validator.ts' -import type { SessionState, SelectResult, DeselectResult } from './state_types.ts' - -export interface LoadResult { - valid: boolean - issues: ValidationResult['issues'] - entityId: EntityID -} - -export interface ViceSession { - select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult - deselect(path: EntityPathKey): DeselectResult - getState(): SessionState - reset(): SessionState -} - -export interface ViceEngine { - load(collection: Collection, entityId: EntityID): LoadResult - createSession(): ViceSession -} - -export type { ViceEngine as default } \ No newline at end of file diff --git a/src/vice/libs/gac.ts b/src/vice/libs/gac.ts deleted file mode 100644 index a180132..0000000 --- a/src/vice/libs/gac.ts +++ /dev/null @@ -1,343 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// VICE · GAC (Generalized Arc Consistency) -// -// Responsabilidades: -// - Definir las estructuras del grafo (GacGraph, GacState) -// - Propagar cambios de dominio mediante GAC -// - Devolver el estado arc-consistent resultante -// -// No conoce Collection, Session ni ningún tipo de runtime externo. -// Opera exclusivamente sobre GacGraph y GacState. -// ───────────────────────────────────────────────────────────────────────────── - -import type { LingString } from '@/ling/lib' -import type { - AttrID, - GroupID, - EntityID, - OptionID, - EntityPathKey, - RuleAction, - AttributeType, - Severity, - OptionSelection, -} from './types.ts' -import type {JsonLogic} from "@/jslg/lib"; - -// ───────────────────────────────────────────────────────────────────────────── -// VARIABLE -// Un atributo en el grafo. Su dominio se reduce por GAC. -// ───────────────────────────────────────────────────────────────────────────── - -export interface GacVariable { - path: EntityPathKey - attrId: AttrID - type: AttributeType - fullDomain: ReadonlySet // dominio del catálogo — nunca muta - defaultOption: OptionID - dependants: EntityPathKey[] // paths de constraints que dependen de esta variable -} - -// ───────────────────────────────────────────────────────────────────────────── -// GROUP -// Un grupo en el grafo. Solo importa su kind para saber el estado inicial. -// ───────────────────────────────────────────────────────────────────────────── - -export interface GacGroup { - path: EntityPathKey - groupId: GroupID - kind: 'fixed' | 'optional' -} - -// ───────────────────────────────────────────────────────────────────────────── -// CONSTRAINT -// Una regla compilada y lista para evaluar. Inmutable. -// ───────────────────────────────────────────────────────────────────────────── - -export interface GacConstraint { - id: string - scope: EntityPathKey - dependsOn: EntityPathKey[] // variables que condition referencia - condition: JsonLogic | null // null = siempre activa - action: RuleAction - message: LingString - severity: Severity - mode: 'after' | 'override' -} - -// ───────────────────────────────────────────────────────────────────────────── -// GAC GRAPH -// Construido una vez en load(). Inmutable en runtime. -// ───────────────────────────────────────────────────────────────────────────── - -export interface GacGraph { - entityId: EntityID - variables: Map - groups: Map - constraints: Map - /** Índice inverso: variable/group → IDs de constraints que la referencian en dependsOn */ - dependantIndex: Map> -} - -// ───────────────────────────────────────────────────────────────────────────── -// GAC STATE -// Copia mutable por sesión. Nunca muta GacGraph. -// ───────────────────────────────────────────────────────────────────────────── - -export interface GacVariableState { - path: EntityPathKey - domain: Set // dominio reducido por GAC - current: OptionSelection | null - active: boolean - violations: GacViolation[] -} - -export interface GacGroupState { - path: EntityPathKey - active: boolean -} - -export interface GacViolation { - constraintId: string - optionId?: OptionID - reason: LingString - severity: Severity -} - -export interface GacState { - variables: Map - groups: Map - consistent: boolean -} - -// ───────────────────────────────────────────────────────────────────────────── -// GAC RESULT -// ───────────────────────────────────────────────────────────────────────────── - -export interface GacResult { - state: GacState - consistent: boolean - active: Set // IDs de constraints activos en esta propagación -} - -// ───────────────────────────────────────────────────────────────────────────── -// JSON LOGIC EVALUATOR -// Inyectado desde fuera — el motor no depende de la librería directamente. -// ───────────────────────────────────────────────────────────────────────────── - -export type JsonLogicEvaluator = (logic: JsonLogic, data: Record) => unknown - -// ───────────────────────────────────────────────────────────────────────────── -// GAC ENGINE -// ───────────────────────────────────────────────────────────────────────────── - -export class GacEngine { - - constructor(private readonly evalLogic: JsonLogicEvaluator) {} - - // ── Propagación ───────────────────────────────────────────────────────────── - - propagate(graph: GacGraph, state: GacState): GacResult { - const active = new Set() - const queue = new Set(graph.constraints.keys()) - - while (queue.size > 0) { - const cid = this.dequeue(queue) - const constraint = graph.constraints.get(cid) - if (!constraint) continue - - if (!this.evalCondition(constraint, state)) continue - - active.add(cid) - - const reduced = this.applyAction(constraint, state, graph) - const targetPath = this.resolveTarget(constraint, graph) - - if (reduced && targetPath) { - for (const dep of graph.dependantIndex.get(targetPath) ?? new Set()) { - queue.add(dep) - } - } - - // dominio vacío = estado inconsistente - if (targetPath) { - const vs = state.variables.get(targetPath) - if (vs && vs.domain.size === 0) state.consistent = false - } - } - - return { state, consistent: state.consistent, active } - } - - // ── Evaluación de condición ────────────────────────────────────────────────── - - private evalCondition(constraint: GacConstraint, state: GacState): boolean { - if (!constraint.condition) return true - const data = this.buildContext(constraint.dependsOn, state) - return Boolean(this.evalLogic(constraint.condition, data)) - } - - private buildContext(paths: EntityPathKey[], state: GacState): Record { - const data: Record = {} - for (const path of paths) { - const vs = state.variables.get(path) - if (!vs) continue - const attrId = path.split('/').pop() ?? path - data[attrId] = vs.current?.optionId ?? null - if (vs.current?.quantity !== undefined) { - data[`${attrId}:quantity`] = vs.current.quantity - } - } - return data - } - - // ── Aplicación de acción ───────────────────────────────────────────────────── - - private applyAction(constraint: GacConstraint, state: GacState, graph: GacGraph): boolean { - const targetPath = this.resolveTarget(constraint, graph) - if (!targetPath) return false - - const { type, values } = constraint.action - - // activate / deactivate pueden apuntar a grupo o atributo opcional - if (type === 'activate' || type === 'deactivate') { - const gs = state.groups.get(targetPath) - if (gs) { - const before = gs.active - gs.active = type === 'activate' - return gs.active !== before - } - const vs = state.variables.get(targetPath) - if (!vs) return false - const before = vs.active - vs.active = type === 'activate' - if (!vs.active) vs.domain.clear() - return vs.active !== before - } - - const vs = state.variables.get(targetPath) - if (!vs) return false - - const before = vs.domain.size - - switch (type) { - case 'forbid': { - for (const optId of values ?? []) { - vs.domain.delete(optId) - if (vs.current?.optionId === optId) { - vs.violations.push({ constraintId: constraint.id, optionId: optId, reason: constraint.message, severity: constraint.severity }) - } - } - break - } - case 'allow': { - const allowed = new Set(values ?? []) - for (const optId of [...vs.domain]) { - if (!allowed.has(optId)) { - vs.domain.delete(optId) - if (vs.current?.optionId === optId) { - vs.violations.push({ constraintId: constraint.id, optionId: optId, reason: constraint.message, severity: constraint.severity }) - } - } - } - break - } - case 'require': { - if (!vs.current) { - vs.violations.push({ constraintId: constraint.id, reason: constraint.message, severity: constraint.severity }) - } - if ((values?.length ?? 0) > 0) { - const required = new Set(values) - if (vs.current && !required.has(vs.current.optionId)) { - vs.violations.push({ constraintId: constraint.id, optionId: vs.current.optionId, reason: constraint.message, severity: constraint.severity }) - } - } - break - } - case 'suggest': { - const suggested = new Set(values ?? []) - if (vs.current && !suggested.has(vs.current.optionId)) { - vs.violations.push({ constraintId: constraint.id, optionId: vs.current.optionId, reason: constraint.message, severity: 'info' }) - } - break - } - } - - return vs.domain.size < before - } - - // ── resolveTarget ──────────────────────────────────────────────────────────── - - private resolveTarget(constraint: GacConstraint, graph: GacGraph): EntityPathKey | null { - const target = constraint.action.target - if (!target) return null - for (const path of graph.variables.keys()) { - if (path.endsWith(`/${target}`) || path === target) return path - } - for (const path of graph.groups.keys()) { - if (path.endsWith(`/${target}`) || path === target) return path - } - return null - } - - private dequeue(queue: Set): string { - const first = queue.values().next().value as string - queue.delete(first) - return first - } - - // ── Estado ─────────────────────────────────────────────────────────────────── - - initState(graph: GacGraph): GacState { - const variables = new Map() - const groups = new Map() - - for (const [path, v] of graph.variables) { - variables.set(path, { - path, - domain: new Set(v.fullDomain), - current: v.type === 'required' ? { optionId: v.defaultOption } : null, - active: v.type !== 'optional', - violations: [], - }) - } - - for (const [path, g] of graph.groups) { - groups.set(path, { path, active: g.kind === 'fixed' }) - } - - return { variables, groups, consistent: true } - } - - applySelection(state: GacState, path: EntityPathKey, optionId: OptionID, quantity?: number): GacState { - const next = this.cloneState(state) - const vs = next.variables.get(path) - if (!vs) return next - vs.current = { optionId, ...(quantity !== undefined ? { quantity } : {}) } - vs.active = true - vs.violations = [] - return next - } - - applyDeselection(state: GacState, path: EntityPathKey): GacState { - const next = this.cloneState(state) - const vs = next.variables.get(path) - if (!vs) return next - vs.current = null - vs.active = false - vs.violations = [] - return next - } - - cloneState(state: GacState): GacState { - const variables = new Map() - const groups = new Map() - for (const [path, v] of state.variables) { - variables.set(path, { ...v, domain: new Set(v.domain), violations: [...v.violations] }) - } - for (const [path, g] of state.groups) { - groups.set(path, { ...g }) - } - return { variables, groups, consistent: state.consistent } - } -} \ No newline at end of file diff --git a/src/vice/libs/graph_builder.ts b/src/vice/libs/graph_builder.ts deleted file mode 100644 index 2f4111f..0000000 --- a/src/vice/libs/graph_builder.ts +++ /dev/null @@ -1,252 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// VICE · Graph Builder -// -// Construye el GacGraph desde el modelo del catálogo. -// Se ejecuta una vez en load() — el grafo es inmutable en runtime. -// -// Responsabilidades: -// - Recorrer Collection → Entity → Group → Attribute → Rule -// - Crear GacVariable por cada Attribute -// - Crear GacGroup por cada Group -// - Crear GacConstraint por cada Rule en cualquier nivel -// - Inferir Rule.dependsOn desde JsonLogic si no está declarado -// - Construir dependantIndex (grafo inverso) -// ───────────────────────────────────────────────────────────────────────────── - -import type { - Collection, - Entity, - Group, - Attribute, - Rule, - EntityID, - GroupID, - EntityPath, - EntityPathKey, -} from './types.ts' - -import type { - GacGraph, - GacVariable, - GacGroup, - GacConstraint, -} from './gac.ts' -import type {JsonLogic} from "@/tsat/lib/types"; - -// ───────────────────────────────────────────────────────────────────────────── -// HELPERS -// ───────────────────────────────────────────────────────────────────────────── - -function toKey(path: EntityPath): EntityPathKey { - return path.join('/') -} - -/** - * Extrae las referencias { var: 'at_xxx' } de un JsonLogic. - * Usado para inferir Rule.dependsOn cuando no está declarado. - */ -function extractVars(logic: JsonLogic, found: Set = new Set()): Set { - if (logic === null || typeof logic !== 'object') return found - if (Array.isArray(logic)) { - for (const item of logic) extractVars(item as JsonLogic, found) - return found - } - const obj = logic as Record - if ('var' in obj && typeof obj['var'] === 'string') { - found.add(obj['var']) - return found - } - for (const val of Object.values(obj)) { - extractVars(val as JsonLogic, found) - } - return found -} - -// ───────────────────────────────────────────────────────────────────────────── -// BUILDER -// ───────────────────────────────────────────────────────────────────────────── - -export class GacGraphBuilder { - - private variables = new Map() - private groups = new Map() - private constraints = new Map() - private constraintSeq = 0 - - build(collection: Collection, entityId: EntityID): GacGraph { - const entity = collection.entities[entityId] - if (!entity) throw new Error(`[VICE] Entity "${entityId}" not found`) - - // 1 — Atributos globales del catálogo (contexto — fuente de constraints) - for (const attr of collection.attributes) { - this.registerVariable(attr, [attr.id]) - } - - // 2 — Atributos globales de la entidad - for (const attr of entity.attributes) { - this.registerVariable(attr, [entityId, attr.id]) - } - - // 3 — Reglas de la entidad - for (const rule of entity.rules ?? []) { - this.registerConstraint(rule, [entityId]) - } - - // 4 — Grupos y sus atributos - for (const [groupId, group] of Object.entries(entity.groups) as [GroupID, Group][]) { - const groupPath: EntityPath = [entityId, groupId] - - // Registrar grupo - this.groups.set(toKey(groupPath), { - path: toKey(groupPath), - groupId: groupId as GroupID, - kind: group.kind, - }) - - // Atributos del grupo - for (const attr of group.attributes) { - this.registerVariable(attr, [entityId, groupId, attr.id]) - } - - // Reglas del grupo (solo OptionalGroup) - if (group.kind === 'optional') { - for (const rule of group.rules ?? []) { - this.registerConstraint(rule, groupPath) - } - } - } - - // 5 — Inferir dependsOn desde JsonLogic donde no está declarado - this.resolveInferredDependsOn() - - // 6 — Construir dependantIndex - const dependantIndex = this.buildDependantIndex() - - return { - entityId, - variables: this.variables, - groups: this.groups, - constraints: this.constraints, - dependantIndex, - } - } - - // ── Registro de variables ────────────────────────────────────────────────── - - private registerVariable(attr: Attribute, path: EntityPath): void { - const key = toKey(path) - - const fullDomain = this.extractDomain(attr) - const defaultOption = this.extractDefault(attr) - - this.variables.set(key, { - path : key, - attrId: attr.id, - type: attr.type, - fullDomain: new Set(fullDomain), - defaultOption, - dependants: (attr.dependants ?? []).map(toKey), - }) - - // Reglas del atributo - const rules = attr.type !== 'fixed' ? (attr.rules ?? []) : [] - for (const rule of rules) { - this.registerConstraint(rule, path) - } - - // Reglas de las opciones - if (attr.type === 'optional' || attr.type === 'required') { - for (const attrOption of attr.options) { - for (const rule of attrOption.rules ?? []) { - this.registerConstraint(rule, [...path, attrOption.optionId]) - } - } - } - } - - // ── Registro de constraints ──────────────────────────────────────────────── - - private registerConstraint(rule: Rule, scope: EntityPath): void { - const id = rule.id ?? (`cn_auto_${++this.constraintSeq}` as any) - - const dependsOn = rule.dependsOn - ? rule.dependsOn.map(toKey) - : [] // se infiere en resolveInferredDependsOn() - - this.constraints.set(id, { - id, - scope: toKey(scope), - dependsOn, - condition: rule.condition ?? null, - action: rule.action, - message: rule.message, - severity: rule.severity, - mode: rule.mode ?? 'after', - }) - } - - // ── Inferencia de dependsOn ──────────────────────────────────────────────── - - private resolveInferredDependsOn(): void { - for (const [id, constraint] of this.constraints) { - if (constraint.dependsOn.length > 0) continue // ya declarado - if (!constraint.condition) continue // sin condición = sin dependencias - - const varNames = extractVars(constraint.condition) - const resolved: EntityPathKey[] = [] - - for (const varName of varNames) { - // Buscar variable cuyo attrId coincide con varName - for (const [key, variable] of this.variables) { - if (variable.attrId === varName) { - resolved.push(key) - break - } - } - } - - this.constraints.set(id, { ...constraint, dependsOn: resolved }) - } - } - - // ── dependantIndex ───────────────────────────────────────────────────────── - - private buildDependantIndex(): Map> { - const index = new Map>() - - // Desde dependsOn de cada constraint - for (const [constraintId, constraint] of this.constraints) { - for (const varPath of constraint.dependsOn) { - if (!index.has(varPath)) index.set(varPath, new Set()) - index.get(varPath)!.add(constraintId) - } - } - - // Desde dependants declarados en las variables - for (const [varPath, variable] of this.variables) { - for (const dep of variable.dependants) { - if (!index.has(varPath)) index.set(varPath, new Set()) - index.get(varPath)!.add(dep) - } - } - - return index - } - - // ── Helpers de extracción ────────────────────────────────────────────────── - - private extractDomain(attr: Attribute) { - if (attr.type === 'fixed') return [attr.option.optionId] - if (attr.type === 'optional') return attr.options.map(o => o.optionId) - if (attr.type === 'required') return attr.options.map(o => o.optionId) - return [] - } - - private extractDefault(attr: Attribute) { - if (attr.type === 'fixed') return attr.option.optionId - if (attr.type === 'optional') return attr.default - if (attr.type === 'required') return attr.default - const _: never = attr - throw new Error('[VICE] Unknown attribute type') - } -} \ No newline at end of file diff --git a/src/vice/libs/index.ts b/src/vice/libs/index.ts deleted file mode 100644 index f026ac6..0000000 --- a/src/vice/libs/index.ts +++ /dev/null @@ -1,8 +0,0 @@ - - -export * from './types.ts'; -export * from './engine_types.ts'; -export * from './state_types.ts' -export * from './validator_types.ts'; - - diff --git a/src/vice/libs/session.ts b/src/vice/libs/session.ts deleted file mode 100644 index 284d2bc..0000000 --- a/src/vice/libs/session.ts +++ /dev/null @@ -1,121 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// VICE · Session -// ───────────────────────────────────────────────────────────────────────────── - -import type {OptionID, EntityPathKey} from './types.ts' -import type {GacGraph, GacState} from './gac.ts' -import type {SessionState, PropagationResult, SelectResult, DeselectResult} from './state_types.ts' -import type {ViceSession} from './engine_types.ts' -import {GacEngine} from './gac.ts' - - -export class Session implements ViceSession { - - private state: GacState - - constructor( - private readonly graph: GacGraph, - private readonly engine: GacEngine, - ) { - this.state = engine.initState(graph) - this.state = engine.propagate(graph, this.state).state - } - - select(path: EntityPathKey, optionId: OptionID, quantity?: number): SelectResult { - const vs = this.state.variables.get(path) - if (!vs) return {ok: false, reason: 'INVALID_PATH'} - if (!vs.active) return {ok: false, reason: 'INACTIVE_ATTRIBUTE'} - if (!vs.domain.has(optionId)) return {ok: false, reason: 'OPTION_NOT_IN_DOMAIN'} - - const prev = this.state - const next = this.engine.applySelection(this.state, path, optionId, quantity) - const result = this.engine.propagate(this.graph, next) - this.state = result.state - - return {ok: true, result: this.toPropagationResult(result.state, prev)} - } - - deselect(path: EntityPathKey): DeselectResult { - const vs = this.state.variables.get(path) - if (!vs) return {ok: false, reason: 'INVALID_PATH'} - if (!vs.active) return {ok: false, reason: 'INACTIVE_ATTRIBUTE'} - - const prev = this.state - const next = this.engine.applyDeselection(this.state, path) - const result = this.engine.propagate(this.graph, next) - this.state = result.state - - return {ok: true, result: this.toPropagationResult(result.state, prev)} - } - - getState(): SessionState { - return this.toSessionState(this.state) - } - - reset(): SessionState { - this.state = this.engine.initState(this.graph) - this.state = this.engine.propagate(this.graph, this.state).state - return this.toSessionState(this.state) - } - - private toSessionState(state: GacState): SessionState { - const attributes = new Map() - const groups = new Map() - - for (const [path, vs] of state.variables) { - const variable = this.graph.variables.get(path) - if (!variable) continue - attributes.set(path, { - path, - attrId: variable.attrId, - active: vs.active, - domain: vs.domain, - current: vs.current, - violations: vs.violations.map(v => ({ - constraintId: v.constraintId, - optionId: v.optionId, - reason: v.reason, - severity: v.severity, - })), - }) - } - - for (const [path, gs] of state.groups) { - const group = this.graph.groups.get(path) - if (!group) continue - groups.set(path, {path, groupId: group.groupId, active: gs.active}) - } - - return {entityId: this.graph.entityId, attributes, groups, consistent: state.consistent} - } - - private toPropagationResult(next: GacState, prev: GacState): PropagationResult { - const changed = new Set() - - for (const [path, vs] of next.variables) { - const prevVs = prev.variables.get(path) - if (!prevVs) { - changed.add(path); - continue - } - if ( - vs.active !== prevVs.active || - vs.current?.optionId !== prevVs.current?.optionId || - !setsEqual(vs.domain, prevVs.domain) - ) changed.add(path) - } - - for (const [path, gs] of next.groups) { - const prevGs = prev.groups.get(path) - if (!prevGs || gs.active !== prevGs.active) changed.add(path) - } - - return {state: this.toSessionState(next), consistent: next.consistent, changed} - } -} - -function setsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) return false - for (const v of a) if (!b.has(v)) return false - return true -} \ No newline at end of file diff --git a/src/vice/libs/state_types.ts b/src/vice/libs/state_types.ts deleted file mode 100644 index 8a6fb4f..0000000 --- a/src/vice/libs/state_types.ts +++ /dev/null @@ -1,95 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// VICE · Runtime State -// -// Tipos del estado en runtime expuestos hacia fuera. -// Son la traducción del GacState interno al contrato público de la sesión. -// ───────────────────────────────────────────────────────────────────────────── - -import type { LingString } from '@/ling/lib' -import type { - AttrID, - GroupID, - OptionID, - EntityPathKey, - Severity, - OptionSelection, -} from './types.ts' - -// ───────────────────────────────────────────────────────────────────────────── -// VIOLATION -// Una restricción activa sobre un atributo o una opción concreta. -// ───────────────────────────────────────────────────────────────────────────── - -export interface Violation { - constraintId: string - optionId?: OptionID // undefined = violación sobre el atributo completo - reason: LingString - severity: Severity -} - -// ───────────────────────────────────────────────────────────────────────────── -// ATTRIBUTE STATE -// Estado de un atributo en un momento dado. -// ───────────────────────────────────────────────────────────────────────────── - -export interface AttributeState { - path: EntityPathKey - attrId: AttrID - active: boolean - domain: ReadonlySet // opciones disponibles tras GAC - current: OptionSelection | null - violations: Violation[] -} - -// ───────────────────────────────────────────────────────────────────────────── -// GROUP STATE -// Estado de un grupo en un momento dado. -// ───────────────────────────────────────────────────────────────────────────── - -export interface GroupState { - path: EntityPathKey - groupId: GroupID - active: boolean -} - -// ───────────────────────────────────────────────────────────────────────────── -// SESSION STATE -// Snapshot completo de una sesión — serializable. -// ───────────────────────────────────────────────────────────────────────────── - -export interface SessionState { - entityId: string - attributes: Map - groups: Map - consistent: boolean -} - -// ───────────────────────────────────────────────────────────────────────────── -// PROPAGATION RESULT -// Lo que devuelve cada select() — qué cambió. -// ───────────────────────────────────────────────────────────────────────────── - -export interface PropagationResult { - state: SessionState - consistent: boolean - /** Paths de atributos cuyo dominio o estado cambió en esta propagación */ - changed: Set -} - - -// ───────────────────────────────────────────────────────────────────────────── -// SELECT / DESELECT RESULT -// ───────────────────────────────────────────────────────────────────────────── - -export type SelectErrorReason = - | 'INVALID_PATH' - | 'INACTIVE_ATTRIBUTE' - | 'OPTION_NOT_IN_DOMAIN' - -export type SelectResult = - | { ok: true; result: PropagationResult } - | { ok: false; reason: SelectErrorReason } - -export type DeselectResult = - | { ok: true; result: PropagationResult } - | { ok: false; reason: 'INVALID_PATH' | 'INACTIVE_ATTRIBUTE' } \ No newline at end of file diff --git a/src/vice/libs/validator.ts b/src/vice/libs/validator.ts deleted file mode 100644 index 64e7576..0000000 --- a/src/vice/libs/validator.ts +++ /dev/null @@ -1,164 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// VICE · Validator -// -// Validación design time del GacGraph. -// Se ejecuta después de GacGraphBuilder.build() en load(). -// Solo opera sobre GacGraph — no conoce Collection. -// -// Errores (bloquean la sesión): -// - MISSING_REFERENCE — target o dependsOn inexistente en el grafo -// - INVALID_DEFAULT — default no está en el dominio -// - EMPTY_DOMAIN — atributo sin opciones -// - DUPLICATE_ID — ID repetido en el mismo scope -// -// Warnings (no bloquean): -// - DEAD_OPTION — opción prohibida incondicionalmente -// - REDUNDANT_RULE — regla que nunca puede activarse -// ───────────────────────────────────────────────────────────────────────────── - -import type { GacGraph } from './gac.ts' -import type {DesignIssue, ValidationResult} from "@/vice/libs/validator_types.ts"; - - - -// ───────────────────────────────────────────────────────────────────────────── -// VALIDATOR -// ───────────────────────────────────────────────────────────────────────────── - -export class GacValidator { - - validate(graph: GacGraph): ValidationResult { - const issues: DesignIssue[] = [] - - this.checkMissingReferences(graph, issues) - this.checkInvalidDefaults(graph, issues) - this.checkEmptyDomains(graph, issues) - this.checkDeadOptions(graph, issues) - this.checkRedundantRules(graph, issues) - - return { - valid: !issues.some(i => i.severity === 'error'), - issues, - } - } - - // ── MISSING_REFERENCE ────────────────────────────────────────────────────── - - private checkMissingReferences(graph: GacGraph, issues: DesignIssue[]): void { - for (const constraint of graph.constraints.values()) { - - // target debe existir como variable o grupo - const tid = constraint.action.target - if (tid) { - const found = - [...graph.variables.keys()].some(k => k.endsWith(`/${tid}`) || k === tid) || - [...graph.groups.keys()].some(k => k.endsWith(`/${tid}`) || k === tid) - - if (!found) { - issues.push({ - code: 'MISSING_REFERENCE', - severity: 'error', - message: `Rule "${constraint.id}" references unknown target: "${tid}"`, - path: constraint.scope, - ruleId: constraint.id, - }) - } - } - - // dependsOn deben existir como variables - for (const dep of constraint.dependsOn) { - if (!graph.variables.has(dep)) { - issues.push({ - code: 'MISSING_REFERENCE', - severity: 'error', - message: `Rule "${constraint.id}" references unknown dependency: "${dep}"`, - path: constraint.scope, - ruleId: constraint.id, - }) - } - } - } - } - - // ── INVALID_DEFAULT ──────────────────────────────────────────────────────── - - private checkInvalidDefaults(graph: GacGraph, issues: DesignIssue[]): void { - for (const v of graph.variables.values()) { - if (!v.fullDomain.has(v.defaultOption)) { - issues.push({ - code: 'INVALID_DEFAULT', - severity: 'error', - message: `Attribute "${v.attrId}" default "${v.defaultOption}" is not in its domain`, - path: v.path, - }) - } - } - } - - // ── EMPTY_DOMAIN ─────────────────────────────────────────────────────────── - - private checkEmptyDomains(graph: GacGraph, issues: DesignIssue[]): void { - for (const v of graph.variables.values()) { - if (v.fullDomain.size === 0) { - issues.push({ - code: 'EMPTY_DOMAIN', - severity: 'error', - message: `Attribute "${v.attrId}" has no options`, - path: v.path, - }) - } - } - } - - // ── DEAD_OPTION ──────────────────────────────────────────────────────────── - // Una opción está muerta si una regla incondicional la prohíbe siempre. - - private checkDeadOptions(graph: GacGraph, issues: DesignIssue[]): void { - for (const v of graph.variables.values()) { - for (const optionId of v.fullDomain) { - const dead = [...graph.constraints.values()].some(c => { - const tid = c.action.target - if (!tid) return false - const targetsThis = v.path.endsWith(`/${tid}`) || v.path === tid - return targetsThis && - !c.condition && - c.action.type === 'forbid' && - c.action.values?.includes(optionId) - }) - - if (dead) { - issues.push({ - code: 'DEAD_OPTION', - severity: 'warning', - message: `Option "${optionId}" in "${v.attrId}" is unconditionally forbidden`, - path: v.path, - }) - } - } - } - } - - // ── REDUNDANT_RULE ───────────────────────────────────────────────────────── - // Una regla es redundante si alguna de sus dependencias tiene dominio vacío. - - private checkRedundantRules(graph: GacGraph, issues: DesignIssue[]): void { - for (const c of graph.constraints.values()) { - if (!c.condition) continue - - const hasDead = c.dependsOn.some(dep => { - const v = graph.variables.get(dep) - return v && v.fullDomain.size === 0 - }) - - if (hasDead) { - issues.push({ - code: 'REDUNDANT_RULE', - severity: 'warning', - message: `Rule "${c.id}" can never activate — depends on an empty domain`, - path: c.scope, - ruleId: c.id, - }) - } - } - } -} \ No newline at end of file diff --git a/src/vice/libs/validator_types.ts b/src/vice/libs/validator_types.ts deleted file mode 100644 index 3d57626..0000000 --- a/src/vice/libs/validator_types.ts +++ /dev/null @@ -1,27 +0,0 @@ -// ───────────────────────────────────────────────────────────────────────────── -// TIPOS -// ───────────────────────────────────────────────────────────────────────────── - -import type {EntityPathKey} from "@/vice/libs/types.ts"; - -export type DesignIssueCode = - | 'MISSING_REFERENCE' - | 'INVALID_DEFAULT' - | 'EMPTY_DOMAIN' - | 'DUPLICATE_ID' - | 'DEAD_OPTION' - | 'REDUNDANT_RULE' - -export interface DesignIssue { - code: DesignIssueCode - severity: 'error' | 'warning' - message: string - path: EntityPathKey // dónde ocurre el problema - ruleId?: string -} - -export interface ValidationResult { - valid: boolean // false si hay algún error - issues: DesignIssue[] -} -