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