import { type FieldValue, isStruct, type PropertyBag, type PropertyValue, type StructValue, } from '../model/Properties'; import { overrideSites } from '../model/instance'; import type { Project } from '../model/Project'; import type { VariableKind } from '../properties/kinds'; import { emptyFieldValue, isValidFieldName, isValidVariableName, type PropertyDef, type StructFieldDef, variableDef, } from '../properties/PropertySchema'; import { type Removed, removeFirst, restoreAt } from '../util/list'; import { type Command, CommandError } from './Command'; import { renameVariablePorts } from './portCommands'; import { requireBehavior } from './require'; /** The variable's default value is written to `props` as well. */ export class AddVariableCommand implements Command { readonly label: string; readonly def: PropertyDef; constructor( private readonly assetId: string, key: string, kind: VariableKind, ) { this.def = variableDef(key, kind); this.label = `Add variable ${key}`; } execute(project: Project): void { const asset = requireBehavior(project, this.assetId); if (!isValidVariableName(this.def.key)) throw new CommandError(INVALID_NAME); if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`); asset.variables.push(this.def); asset.props[this.def.key] = this.def.default; } undo(project: Project): void { const asset = requireBehavior(project, this.assetId); asset.variables = asset.variables.filter((v) => v.key !== this.def.key); delete asset.props[this.def.key]; } } /** Removes a variable together with its default and every instance override. */ export class RemoveVariableCommand implements Command { readonly label: string; private removed?: { def: Removed; value: PropertyValue | undefined }; private overrides: { holderId: string; objectId: string; path: string; value: PropertyValue }[] = []; constructor( private readonly assetId: string, private readonly key: string, ) { this.label = `Remove variable ${key}`; } execute(project: Project): void { const asset = requireBehavior(project, this.assetId); const def = removeFirst(asset.variables, (v) => v.key === this.key); if (!def) throw new CommandError(`Variable "${this.key}" not found`); this.removed = { def, value: asset.props[this.key] }; delete asset.props[this.key]; this.overrides = []; for (const site of overrideSites(project, (id) => id === this.assetId)) { const value = site.bag[this.key]; if (value === undefined) continue; this.overrides.push({ holderId: site.holder.id, objectId: site.object.id, path: site.path, value }); delete site.bag[this.key]; } } undo(project: Project): void { if (!this.removed) return; const asset = requireBehavior(project, this.assetId); restoreAt(asset.variables, [this.removed.def]); if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value; const sites = overrideSites(project, (id) => id === this.assetId); for (const o of this.overrides) { const site = sites.find( (s) => s.holder.id === o.holderId && s.object.id === o.objectId && s.path === o.path, ); if (site) site.bag[this.key] = o.value; } } } /** Renames everywhere: definition, default, instance overrides and the graph nodes referring to it. */ export class RenameVariableCommand implements Command { readonly label: string; constructor( private readonly assetId: string, private readonly from: string, private readonly to: string, ) { this.label = `Rename variable ${from}`; } execute(project: Project): void { this.rename(project, this.from, this.to); } undo(project: Project): void { this.rename(project, this.to, this.from); } private rename(project: Project, from: string, to: string): void { const asset = requireBehavior(project, this.assetId); if (from === to) return; if (!isValidVariableName(to)) throw new CommandError(INVALID_NAME); const def = asset.variables.find((v) => v.key === from); if (!def) throw new CommandError(`Variable "${from}" not found`); if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`); def.key = to; def.label = to; if (asset.props[from] !== undefined) { asset.props[to] = asset.props[from]!; delete asset.props[from]; } for (const { bag } of overrideSites(project, (id) => id === this.assetId)) { if (bag[from] === undefined) continue; bag[to] = bag[from]!; delete bag[from]; } renameVariablePorts(project, this.assetId, from, to); } } const INVALID_NAME = 'Variable names must start with a letter and contain only letters, digits or _'; const INVALID_FIELD = 'Field names must start with a letter and contain only letters, digits or _'; /** Where declared fields live: on a struct variable. A list item carries its own shape instead. */ interface FieldOwner { fields(): readonly StructFieldDef[]; setFields(next: readonly StructFieldDef[]): void; } function variableOf(project: Project, assetId: string, key: string): PropertyDef | undefined { return requireBehavior(project, assetId).variables.find((v) => v.key === key); } function requireFieldOwner(project: Project, assetId: string, key: string): FieldOwner { const def = variableOf(project, assetId, key); if (def?.kind !== 'struct') throw new CommandError(`Variable "${key}" has no fields`); return { fields: () => def.fields, setFields: (next) => (def.fields = next) }; } /** * One struct-carrying value of a variable (its declared default, the behavior's own, an override); a * field change hits all of them at once so none keeps a stale shape. `id` pairs a site up on undo. */ interface StructSite { id: string; read(): StructValue; write(value: StructValue): void; } function structSites(project: Project, assetId: string, key: string): StructSite[] { return ( valueHolders(project, assetId, key) // An override the object does not have stays absent: writing one here would invent a deviation. .filter((h) => !h.optional || isStruct(h.read())) .map((holder) => ({ id: holder.id, read: () => (isStruct(holder.read()) ? (holder.read() as StructValue) : {}), write: (value: StructValue) => holder.write(value), })) ); } /** Everywhere one variable's value is kept: its declared default, the behavior's own, the overrides. */ interface ValueHolder { id: string; /** An override may simply not be there; the declared default and the behavior's own always are. */ optional: boolean; read(): PropertyValue | undefined; write(value: PropertyValue): void; } function valueHolders(project: Project, assetId: string, key: string): ValueHolder[] { const asset = requireBehavior(project, assetId); const def = asset.variables.find((v) => v.key === key); const holders: ValueHolder[] = []; if (def) holders.push({ id: 'declared', optional: false, read: () => def.default, write: (value) => { def.default = value; }, }); holders.push(bagHolder('asset', false, asset.props, key)); for (const site of overrideSites(project, (id) => id === assetId)) holders.push(bagHolder(`${site.object.id}/${site.path}`, true, site.bag, key)); return holders; } function bagHolder(id: string, optional: boolean, bag: PropertyBag, key: string): ValueHolder { return { id, optional, read: () => bag[key], write: (value: PropertyValue) => (bag[key] = value) }; } function withoutField(value: StructValue, key: string): StructValue { const next: Record = { ...value }; delete next[key]; return next; } /** Rebuilt rather than reassigned, so the field keeps its place in the value as well. */ function renameField(value: StructValue, from: string, to: string): StructValue { const next: Record = {}; for (const [key, item] of Object.entries(value)) next[key === from ? to : key] = item; return next; } export class AddStructFieldCommand implements Command { readonly label: string; constructor( private readonly assetId: string, private readonly key: string, private readonly field: StructFieldDef, ) { this.label = `Add field ${field.key}`; } execute(project: Project): void { const owner = requireFieldOwner(project, this.assetId, this.key); if (!isValidFieldName(this.field.key)) throw new CommandError(INVALID_FIELD); if (owner.fields().some((f) => f.key === this.field.key)) throw new CommandError(`Field "${this.field.key}" already exists`); owner.setFields([...owner.fields(), { ...this.field }]); for (const site of structSites(project, this.assetId, this.key)) site.write({ ...site.read(), [this.field.key]: emptyFieldValue(this.field) }); } undo(project: Project): void { const owner = requireFieldOwner(project, this.assetId, this.key); owner.setFields(owner.fields().filter((f) => f.key !== this.field.key)); for (const site of structSites(project, this.assetId, this.key)) site.write(withoutField(site.read(), this.field.key)); } } /** Takes the field's value out of every struct that carries it, and gives each one back on undo. */ export class RemoveStructFieldCommand implements Command { readonly label: string; private removed?: { field: Removed; values: Map }; constructor( private readonly assetId: string, private readonly key: string, private readonly field: string, ) { this.label = `Remove field ${field}`; } execute(project: Project): void { const owner = requireFieldOwner(project, this.assetId, this.key); const fields = [...owner.fields()]; const field = removeFirst(fields, (f) => f.key === this.field); if (!field) throw new CommandError(`Field "${this.field}" not found`); const values = new Map(); for (const site of structSites(project, this.assetId, this.key)) { const value = site.read(); const item = value[this.field]; if (item !== undefined) values.set(site.id, item); site.write(withoutField(value, this.field)); } owner.setFields(fields); this.removed = { field, values }; } undo(project: Project): void { if (!this.removed) return; const owner = requireFieldOwner(project, this.assetId, this.key); const fields = [...owner.fields()]; restoreAt(fields, [this.removed.field]); owner.setFields(fields); for (const site of structSites(project, this.assetId, this.key)) { const value = this.removed.values.get(site.id); if (value !== undefined) site.write({ ...site.read(), [this.field]: value }); } } } export class RenameStructFieldCommand implements Command { readonly label: string; constructor( private readonly assetId: string, private readonly key: string, private readonly from: string, private readonly to: string, ) { this.label = `Rename field ${from}`; } execute(project: Project): void { this.rename(project, this.from, this.to); } undo(project: Project): void { this.rename(project, this.to, this.from); } private rename(project: Project, from: string, to: string): void { const owner = requireFieldOwner(project, this.assetId, this.key); if (from === to) return; if (!isValidFieldName(to)) throw new CommandError(INVALID_FIELD); const fields = owner.fields(); if (!fields.some((f) => f.key === from)) throw new CommandError(`Field "${from}" not found`); if (fields.some((f) => f.key === to)) throw new CommandError(`Field "${to}" already exists`); owner.setFields(fields.map((f) => (f.key === from ? { ...f, key: to } : f))); for (const site of structSites(project, this.assetId, this.key)) site.write(renameField(site.read(), from, to)); } }