import type { Interface, Widget, SignalRef, PlotLayout, StateVar, LogicGraph } from './types';
/** Escape a string for use as an XML attribute value. */
function xmlEsc(s: string): string {
return s
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(//g, '>');
}
/** Serialize a panel-logic flow graph to indented XML lines. */
function serializeLogic(graph: LogicGraph, lines: string[]): void {
lines.push(` `);
for (const node of graph.nodes) {
lines.push(
` `
);
for (const [key, value] of Object.entries(node.params)) {
lines.push(` `);
}
lines.push(` `);
}
for (const wire of graph.wires) {
const portAttr = wire.fromPort && wire.fromPort !== 'out'
? ` port="${xmlEsc(wire.fromPort)}"` : '';
lines.push(` `);
}
lines.push(` `);
}
/** Serialize a PlotLayout subtree to indented XML lines. */
function serializeLayout(node: PlotLayout, indent: string, lines: string[]): void {
if (node.type === 'leaf') {
lines.push(`${indent}`);
return;
}
lines.push(`${indent}`);
serializeLayout(node.a, indent + ' ', lines);
serializeLayout(node.b, indent + ' ', lines);
lines.push(`${indent}`);
}
/** Serialize an Interface object to an XML string. */
export function serializeInterface(iface: Interface): string {
const lines: string[] = [];
lines.push(``);
const kindAttr = iface.kind === 'plot' ? ` kind="plot"` : '';
lines.push(
``
);
if (iface.kind === 'plot' && iface.layout) {
lines.push(` `);
serializeLayout(iface.layout, ' ', lines);
lines.push(` `);
}
for (const sv of iface.statevars ?? []) {
const attrs = [
`name="${xmlEsc(sv.name)}"`,
`type="${xmlEsc(sv.type ?? 'number')}"`,
`initial="${xmlEsc(sv.initial ?? '')}"`,
];
if (sv.unit) attrs.push(`unit="${xmlEsc(sv.unit)}"`);
if (sv.low !== undefined) attrs.push(`low="${sv.low}"`);
if (sv.high !== undefined) attrs.push(`high="${sv.high}"`);
lines.push(` `);
}
if (iface.logic && (iface.logic.nodes.length > 0 || iface.logic.wires.length > 0)) {
serializeLogic(iface.logic, lines);
}
for (const w of iface.widgets) {
lines.push(
` `
);
for (const sig of w.signals) {
const colorAttr = sig.color ? ` color="${xmlEsc(sig.color)}"` : '';
lines.push(` `);
}
for (const [key, value] of Object.entries(w.options)) {
lines.push(` `);
}
lines.push(` `);
}
lines.push(``);
return lines.join('\n');
}
/**
* Parse an interface XML string into an Interface object.
*
* Expected XML format:
*
*
*
*
*
*
*/
/** Parse a / element into a PlotLayout node. */
function parseLayout(el: Element): PlotLayout {
if (el.tagName === 'leaf') {
return { type: 'leaf', widget: el.getAttribute('widget') ?? '' };
}
// split
const dir = el.getAttribute('dir') === 'v' ? 'v' : 'h';
const ratio = parseFloat(el.getAttribute('ratio') ?? '0.5');
const children = Array.from(el.children).filter(
c => c.tagName === 'split' || c.tagName === 'leaf'
);
const a = children[0] ? parseLayout(children[0]) : { type: 'leaf', widget: '' } as PlotLayout;
const b = children[1] ? parseLayout(children[1]) : { type: 'leaf', widget: '' } as PlotLayout;
return { type: 'split', dir, ratio: isNaN(ratio) ? 0.5 : ratio, a, b };
}
/** Parse a element into a LogicGraph (nodes + wires). */
function parseLogic(logicEl: Element): LogicGraph {
const nodes: LogicGraph['nodes'] = [];
const wires: LogicGraph['wires'] = [];
for (const el of Array.from(logicEl.children)) {
if (el.tagName === 'node') {
const id = el.getAttribute('id') ?? '';
if (!id) continue;
const params: Record = {};
for (const child of Array.from(el.children)) {
if (child.tagName !== 'param') continue;
const key = child.getAttribute('key');
const value = child.getAttribute('value');
if (key !== null && value !== null) params[key] = value;
}
nodes.push({
id,
kind: (el.getAttribute('kind') ?? 'action.write') as LogicGraph['nodes'][number]['kind'],
x: parseFloat(el.getAttribute('x') ?? '0'),
y: parseFloat(el.getAttribute('y') ?? '0'),
params,
});
} else if (el.tagName === 'wire') {
const from = el.getAttribute('from');
const to = el.getAttribute('to');
const port = el.getAttribute('port');
if (from && to) wires.push({ from, to, ...(port ? { fromPort: port } : {}) });
}
}
return { nodes, wires };
}
export function parseInterface(xml: string): Interface {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'application/xml');
// Check for parse errors
const parseError = doc.querySelector('parsererror');
if (parseError) {
throw new Error(`XML parse error: ${parseError.textContent}`);
}
const root = doc.documentElement;
if (root.tagName !== 'interface') {
throw new Error(`Expected root element , got <${root.tagName}>`);
}
const ifaceId = root.getAttribute('id') ?? '';
const name = root.getAttribute('name') ?? 'Untitled';
const version = parseInt(root.getAttribute('version') ?? '1', 10);
const w = parseInt(root.getAttribute('width') ?? '1200', 10);
const h = parseInt(root.getAttribute('height') ?? '800', 10);
const kind = root.getAttribute('kind') === 'plot' ? 'plot' : undefined;
let layout: PlotLayout | undefined;
if (kind === 'plot') {
const layoutEl = Array.from(root.children).find(el => el.tagName === 'layout');
const rootNode = layoutEl
? Array.from(layoutEl.children).find(el => el.tagName === 'split' || el.tagName === 'leaf')
: undefined;
if (rootNode) layout = parseLayout(rootNode);
}
const statevars: StateVar[] = [];
for (const el of Array.from(root.children)) {
if (el.tagName !== 'statevar') continue;
const name = el.getAttribute('name') ?? '';
if (!name) continue;
const type = el.getAttribute('type');
const low = el.getAttribute('low');
const high = el.getAttribute('high');
const unit = el.getAttribute('unit');
statevars.push({
name,
type: type === 'bool' || type === 'string' ? type : 'number',
initial: el.getAttribute('initial') ?? '',
...(unit ? { unit } : {}),
...(low !== null ? { low: parseFloat(low) } : {}),
...(high !== null ? { high: parseFloat(high) } : {}),
});
}
const logicEl = Array.from(root.children).find(el => el.tagName === 'logic');
const logic = logicEl ? parseLogic(logicEl) : null;
const widgets: Widget[] = [];
for (const el of Array.from(root.children)) {
if (el.tagName !== 'widget') continue;
const id = el.getAttribute('id') ?? '';
const type = el.getAttribute('type') ?? 'unknown';
const x = parseFloat(el.getAttribute('x') ?? '0');
const y = parseFloat(el.getAttribute('y') ?? '0');
const w = parseFloat(el.getAttribute('w') ?? '100');
const h = parseFloat(el.getAttribute('h') ?? '60');
const signals: Array = [];
const options: Record = {};
for (const child of Array.from(el.children)) {
if (child.tagName === 'signal') {
const ds = child.getAttribute('ds') ?? '';
const sigName = child.getAttribute('name') ?? '';
const color = child.getAttribute('color') ?? undefined;
signals.push({ ds, name: sigName, ...(color ? { color } : {}) });
} else if (child.tagName === 'option') {
const key = child.getAttribute('key');
const value = child.getAttribute('value');
if (key !== null && value !== null) {
options[key] = value;
}
}
}
widgets.push({ id, type, x, y, w, h, signals, options });
}
return {
id: ifaceId, name, version, w, h, widgets,
...(kind ? { kind } : {}),
...(layout ? { layout } : {}),
...(statevars.length > 0 ? { statevars } : {}),
...(logic && (logic.nodes.length > 0 || logic.wires.length > 0) ? { logic } : {}),
};
}