You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

812 lines
30 KiB

// ─────────────────────────────────────────────────────────────────────────────
// 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<OptionID, OptionDefinition>,
entities: {} as Record<EntityID, Entity>,
}
}
function emptyEntity(id: EntityID): Entity {
return {
id,
code: '',
name: emptyLing(),
description: emptyLing(),
attributes: [],
groups: {} as Record<GroupID, Group>,
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<Collection>(initial ?? emptyCollection())
let activeEntityId = $state<EntityID | null>(null)
let activeGroupId = $state<GroupID | null>(null)
let activeAttrId = $state<AttrID | null>(null)
let activeOptionId = $state<OptionID | null>(null)
let activeRuleIdx = $state<number | null>(null)
let activePanel = $state<EditorPanel>('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<Pick<Collection, 'code' | 'name' | 'description' | 'basePath' | 'langs'>>) {
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<OptionDefinition>) {
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<OptionID, OptionDefinition> }
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<string, unknown>) {
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<EntityID, Entity> }
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<string, unknown>) {
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<GroupID, Group> },
},
}
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<string, unknown>) {
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<string, unknown>) {
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<Rule>,
) {
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<typeof createEditorStore>
// ─────────────────────────────────────────────────────────────────────────────
// 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) },
},
},
}
}

Powered by TurnKey Linux.