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.

639 lines
25 KiB

7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// 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).
// ─────────────────────────────────────────────────────────────────────────────
7 months ago
import type {LingString} from "@/ling";
7 months ago
import type {
7 months ago
Collection, CollectionID,
Entity, EntityID,
Group, GroupID,
Attribute, AttrID,
7 months ago
AttributeOption,
7 months ago
OptionDefinition, OptionID,
Rule,
EntityPath,
EntityPathKey, OptionalGroup, FixedGroup, RequiredAttribute,
} from '@/vice/libs/types.ts'
import {defaultISOLocale} from "@/ling/consts.ts";
7 months ago
// ─────────────────────────────────────────────────────────────────────────────
// 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: '',
7 months ago
langs: [defaultISOLocale],
7 months ago
name: emptyLing(),
description: emptyLing(),
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): 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
// ─────────────────────────────────────────────────────────────────────────────
7 months ago
export type EditorPanel = 'catalog' | 'objects' | 'options' | 'rules' | 'preview' | 'export'
7 months ago
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<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)
7 months ago
let activePanel = $state<EditorPanel>('objects')
let isDirty = $state(false)
function markDirty() { isDirty = true }
7 months ago
// ── Derived ────────────────────────────────────────────────────────────────
7 months ago
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
)
7 months ago
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 ─────────────────────────────────────────────────────────────
7 months ago
function setPanel(panel: EditorPanel) {
activePanel = panel
}
7 months ago
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 ────────────────────────────────────────────────────────
7 months ago
function updateCollectionMeta(patch: Partial<Pick<Collection, 'code' | 'name' | 'description' | 'basePath' | 'langs' >>) {
7 months ago
collection = { ...collection, ...patch }
7 months ago
isDirty = true
7 months ago
}
// ── Options library ────────────────────────────────────────────────────────
function addOption(): OptionID {
const id = uid('op') as OptionID
collection = {
...collection,
options: { ...collection.options, [id]: emptyOption(id) },
}
activeOptionId = id
7 months ago
markDirty()
7 months ago
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 } },
}
7 months ago
markDirty()
7 months ago
}
function deleteOption(id: OptionID) {
const { [id]: _, ...rest } = collection.options
collection = { ...collection, options: rest as Record<OptionID, OptionDefinition> }
if (activeOptionId === id) activeOptionId = null
7 months ago
markDirty()
7 months ago
}
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
7 months ago
markDirty()
7 months ago
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 },
}
7 months ago
markDirty()
7 months ago
}
function deleteEntity(id: EntityID) {
const { [id]: _, ...rest } = collection.entities
collection = { ...collection, entities: rest as Record<EntityID, Entity> }
if (activeEntityId === id) setActiveEntity(null)
7 months ago
markDirty()
7 months ago
}
// ── 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
7 months ago
markDirty()
7 months ago
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 },
},
},
}
7 months ago
markDirty()
7 months ago
}
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 }
7 months ago
markDirty()
7 months ago
}
// ── 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<string, unknown>) {
collection = produceAttr(collection, entityId, groupId, attrId, attr => ({ ...attr, ...patch }) as Attribute)
7 months ago
markDirty()
7 months ago
}
function deleteAttribute(entityId: EntityID, groupId: GroupID | undefined, attrId: AttrID) {
collection = produceAttr(collection, entityId, groupId, attrId, null)
if (activeAttrId === attrId) activeAttrId = null
7 months ago
markDirty()
7 months ago
}
// ── 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
})
7 months ago
markDirty()
7 months ago
}
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
})
7 months ago
markDirty()
7 months ago
}
// ── 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<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
}
// ── Import / Export ────────────────────────────────────────────────────────
function exportToJson(): string {
return JSON.stringify(collection, null, 2)
}
function importFromJson(json: string) {
7 months ago
const parsed = JSON.parse(json)
collection = parsed
7 months ago
activeEntityId = null
activeGroupId = null
activeAttrId = null
activeOptionId = null
7 months ago
isDirty = false
7 months ago
}
function reset() {
collection = emptyCollection()
activeEntityId = null
activeGroupId = null
activeAttrId = null
activeOptionId = null
7 months ago
isDirty = false
7 months ago
}
// ── Public API ─────────────────────────────────────────────────────────────
return {
// State (getters)
get collection() { return collection },
get validation() { return validation },
7 months ago
get activePanel() { return activePanel },
get isDirty() { return isDirty },
get canPreview() { return canPreview },
7 months ago
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
7 months ago
setPanel,
7 months ago
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<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) },
},
}
}

Powered by TurnKey Linux.