// ───────────────────────────────────────────────────────────────────────────── // 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) }, }, }, } }