@aryacode/dsh-engineering 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/vendor/ftview/SKILL.md +34 -5
- package/vendor/ftview/lib/display-tree.d.ts +99 -0
- package/vendor/ftview/lib/display-tree.js +306 -0
- package/vendor/ftview/lib/edit/operations.d.ts +85 -1
- package/vendor/ftview/lib/edit/operations.js +201 -0
- package/vendor/ftview/lib/edit/prepare.d.ts +3 -0
- package/vendor/ftview/lib/edit/prepare.js +35 -1
- package/vendor/ftview/lib/edit/tools.js +249 -5
- package/vendor/ftview/lib/handoff.d.ts +58 -0
- package/vendor/ftview/lib/handoff.js +95 -0
- package/vendor/ftview/lib/index.d.ts +1 -0
- package/vendor/ftview/lib/index.js +3 -1
- package/vendor/ftview/lib/schema.d.ts +68 -0
- package/vendor/ftview/lib/schema.js +156 -0
- package/vendor/ftview/lib/vendor-tools.d.ts +17 -0
- package/vendor/ftview/lib/vendor-tools.js +74 -0
- package/vendor/ftview/lib/vendor.d.ts +56 -0
- package/vendor/ftview/lib/vendor.js +143 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { EngineeringError } from "../core.js";
|
|
11
11
|
import { splitCsv } from "../model.js";
|
|
12
|
+
import { decodeEntities, findNamed, firstChildOfType, insertChild, parseDisplayTree, removeNode, renderElement, setAttribute as setNodeAttribute } from "../display-tree.js";
|
|
12
13
|
/** Escape one XML attribute value. */
|
|
13
14
|
export function escapeAttribute(value) {
|
|
14
15
|
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
|
|
@@ -124,3 +125,203 @@ export function upsertTag(csv, tag) {
|
|
|
124
125
|
lines.push(cells.map(encodeCell).join(';'));
|
|
125
126
|
return lines.join(eol);
|
|
126
127
|
}
|
|
128
|
+
/** Elements that are parts of an object, never objects themselves. */
|
|
129
|
+
const RESERVED = new Set(['gfx', 'display', 'displaySettings', 'connections', 'connection', 'states', 'state', 'caption', 'imageSettings', 'animations', 'parameters', 'parameter', 'font', 'color']);
|
|
130
|
+
/** Depth of one node below the root. */
|
|
131
|
+
function depthOf(root, target) {
|
|
132
|
+
let found = -1;
|
|
133
|
+
const walkNode = (node, depth) => {
|
|
134
|
+
if (found >= 0)
|
|
135
|
+
return;
|
|
136
|
+
if (node === target) {
|
|
137
|
+
found = depth;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
for (const child of node.children)
|
|
141
|
+
walkNode(child, depth + 1);
|
|
142
|
+
};
|
|
143
|
+
walkNode(root, 0);
|
|
144
|
+
return found;
|
|
145
|
+
}
|
|
146
|
+
/** Parent of one node, or undefined for the root. */
|
|
147
|
+
function parentOf(root, target) {
|
|
148
|
+
let found;
|
|
149
|
+
const walkNode = (node) => {
|
|
150
|
+
if (found !== undefined)
|
|
151
|
+
return;
|
|
152
|
+
for (const child of node.children) {
|
|
153
|
+
if (child === target) {
|
|
154
|
+
found = node;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
walkNode(child);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
walkNode(root);
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
/** Validate an object name and element type before writing anything. */
|
|
164
|
+
function assertObjectStyle(type, name) {
|
|
165
|
+
if (!/^[A-Za-z_][\w.:-]*$/u.test(type))
|
|
166
|
+
throw new EngineeringError('INVALID_OBJECT_TYPE', `${JSON.stringify(type)} is not an XML element name.`, 'Pass the object element name as the export writes it, for example "momentaryButton".');
|
|
167
|
+
if (RESERVED.has(type))
|
|
168
|
+
throw new EngineeringError('INVALID_OBJECT_TYPE', `${type} is part of a display, not an object type.`, 'Pass an object element such as "text", "panel", "momentaryButton" or "numericDisplay".');
|
|
169
|
+
if (!/^[A-Za-z_][\w .-]{0,79}$/u.test(name))
|
|
170
|
+
throw new EngineeringError('INVALID_OBJECT_NAME', `${JSON.stringify(name)} is not an object name.`, 'Use letters, digits, spaces, dots, underscores or dashes, at most 80 characters.');
|
|
171
|
+
}
|
|
172
|
+
/** A `<connections>` block at one depth. */
|
|
173
|
+
function connectionsXml(connections, depth) {
|
|
174
|
+
const lines = Object.entries(connections).map(([name, expression]) => {
|
|
175
|
+
if (!/^[A-Za-z_][\w.:-]*$/u.test(name))
|
|
176
|
+
throw new EngineeringError('INVALID_CONNECTION', `${JSON.stringify(name)} is not a connection name.`, 'Pass the connection as the export writes it, for example "Value" or "Visibility".');
|
|
177
|
+
return `${' '.repeat(depth + 1)}<connection name="${escapeAttribute(name)}" expression="${escapeAttribute(expression)}"/>\n`;
|
|
178
|
+
});
|
|
179
|
+
return `${' '.repeat(depth)}<connections>\n${lines.join('')}${' '.repeat(depth)}</connections>\n`;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Build a new display document from a schema-valid skeleton.
|
|
183
|
+
*
|
|
184
|
+
* @param op - Display identity and size.
|
|
185
|
+
* @returns Whole XML text.
|
|
186
|
+
*/
|
|
187
|
+
export function createDisplay(op) {
|
|
188
|
+
const settings = {};
|
|
189
|
+
if (op.displayType !== undefined)
|
|
190
|
+
settings.displayType = op.displayType;
|
|
191
|
+
settings.width = String(op.width);
|
|
192
|
+
settings.height = String(op.height);
|
|
193
|
+
if (op.backColor !== undefined)
|
|
194
|
+
settings.backColor = op.backColor;
|
|
195
|
+
if (op.displayNumber !== undefined)
|
|
196
|
+
settings.displayNumber = String(op.displayNumber);
|
|
197
|
+
const attributes = Object.entries(settings).map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`).join('');
|
|
198
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<gfx xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="Gfx-ME17.xsd">\n <displaySettings${attributes}/>\n</gfx>\n`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Add one object to a display.
|
|
202
|
+
*
|
|
203
|
+
* @param xml - Display XML.
|
|
204
|
+
* @param op - Object type, name, geometry and connections.
|
|
205
|
+
* @returns The modified XML.
|
|
206
|
+
*/
|
|
207
|
+
export function addObject(xml, op) {
|
|
208
|
+
assertObjectStyle(op.type, op.name);
|
|
209
|
+
const tree = parseDisplayTree(xml);
|
|
210
|
+
if (op.parent !== undefined) {
|
|
211
|
+
const parent = findNamed(tree.root, op.parent);
|
|
212
|
+
if (parent === undefined)
|
|
213
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.parent)} in this display.`, 'Call ftview_get to see the object names, then add inside an existing group.');
|
|
214
|
+
const depth = depthOf(tree.root, parent);
|
|
215
|
+
const attributes = { name: escapeAttribute(op.name), ...op.attributes };
|
|
216
|
+
const innerConnections = op.connections === undefined ? '' : connectionsXml(op.connections, depth + 1);
|
|
217
|
+
const element = renderElement(op.type, attributes, innerConnections === '' ? undefined : innerConnections, depth + 1);
|
|
218
|
+
return insertChild(tree, parent, element, op.index);
|
|
219
|
+
}
|
|
220
|
+
const attributes = { name: escapeAttribute(op.name), ...op.attributes };
|
|
221
|
+
if (op.caption !== undefined && attributes.caption === undefined)
|
|
222
|
+
attributes.caption = escapeAttribute(op.caption);
|
|
223
|
+
const innerConnections = op.connections === undefined ? '' : connectionsXml(op.connections, 1);
|
|
224
|
+
const element = renderElement(op.type, attributes, innerConnections === '' ? undefined : innerConnections, 1);
|
|
225
|
+
return insertChild(tree, tree.root, element, op.index);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Change attributes and connections on an existing object.
|
|
229
|
+
*
|
|
230
|
+
* @param xml - Display XML.
|
|
231
|
+
* @param op - Target object and the changes.
|
|
232
|
+
* @returns The modified XML.
|
|
233
|
+
*/
|
|
234
|
+
export function setObject(xml, op) {
|
|
235
|
+
let current = xml;
|
|
236
|
+
for (const [name, value] of Object.entries(op.attributes)) {
|
|
237
|
+
if (!/^[A-Za-z_][\w.:-]*$/u.test(name))
|
|
238
|
+
throw new EngineeringError('INVALID_ATTRIBUTE', `${JSON.stringify(name)} is not an XML attribute name.`, 'Pass a plain attribute name such as "visible" or "left".');
|
|
239
|
+
const tree = parseDisplayTree(current);
|
|
240
|
+
const node = findNamed(tree.root, op.object);
|
|
241
|
+
if (node === undefined)
|
|
242
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
|
|
243
|
+
current = setNodeAttribute(tree, node, name, value);
|
|
244
|
+
}
|
|
245
|
+
if (op.caption !== undefined) {
|
|
246
|
+
if (op.caption.length > 2000)
|
|
247
|
+
throw new EngineeringError('INVALID_CAPTION', 'A caption is limited to 2000 characters.', 'Shorten the caption.');
|
|
248
|
+
const tree = parseDisplayTree(current);
|
|
249
|
+
const node = findNamed(tree.root, op.object);
|
|
250
|
+
if (node === undefined)
|
|
251
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
|
|
252
|
+
current = setNodeAttribute(tree, node, 'caption', op.caption);
|
|
253
|
+
}
|
|
254
|
+
if (op.connections !== undefined) {
|
|
255
|
+
for (const name of Object.keys(op.connections)) {
|
|
256
|
+
if (!/^[A-Za-z_][\w.:-]*$/u.test(name))
|
|
257
|
+
throw new EngineeringError('INVALID_CONNECTION', `${JSON.stringify(name)} is not a connection name.`, 'Pass the connection name as the export writes it.');
|
|
258
|
+
const tree = parseDisplayTree(current);
|
|
259
|
+
const node = findNamed(tree.root, op.object);
|
|
260
|
+
if (node === undefined)
|
|
261
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
|
|
262
|
+
const expression = op.connections[name];
|
|
263
|
+
let block = firstChildOfType(node, 'connections');
|
|
264
|
+
if (block === undefined) {
|
|
265
|
+
const depth = depthOf(tree.root, node);
|
|
266
|
+
const inserted = insertChild(tree, node, connectionsXml({ [name]: expression }, depth + 1));
|
|
267
|
+
current = inserted;
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const existing = block.children.find(child => child.type === 'connection' && decodeEntities(child.attributes.name ?? '') === name);
|
|
271
|
+
if (existing !== undefined) {
|
|
272
|
+
const existingTree = parseDisplayTree(current);
|
|
273
|
+
const existingBlock = findNamed(existingTree.root, op.object);
|
|
274
|
+
if (existingBlock === undefined)
|
|
275
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
|
|
276
|
+
const connection = existingBlock.children.find(child => child.type === 'connections')?.children.find(child => child.type === 'connection' && decodeEntities(child.attributes.name ?? '') === name);
|
|
277
|
+
if (connection === undefined)
|
|
278
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `Connection ${JSON.stringify(name)} disappeared while editing.`, 'Reopen the display and retry.');
|
|
279
|
+
current = setNodeAttribute(existingTree, connection, 'expression', expression);
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
const depth = depthOf(tree.root, block);
|
|
283
|
+
current = insertChild(tree, block, `${' '.repeat(depth + 1)}<connection name="${escapeAttribute(name)}" expression="${escapeAttribute(expression)}"/>\n`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return current;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Remove one object.
|
|
291
|
+
*
|
|
292
|
+
* @param xml - Display XML.
|
|
293
|
+
* @param op - Target object.
|
|
294
|
+
* @returns The modified XML.
|
|
295
|
+
*/
|
|
296
|
+
export function deleteObject(xml, op) {
|
|
297
|
+
const tree = parseDisplayTree(xml);
|
|
298
|
+
const node = findNamed(tree.root, op.object);
|
|
299
|
+
if (node === undefined)
|
|
300
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
|
|
301
|
+
return removeNode(tree, node);
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Move one object within its siblings.
|
|
305
|
+
*
|
|
306
|
+
* @param xml - Display XML.
|
|
307
|
+
* @param op - Target object and the new index.
|
|
308
|
+
* @returns The modified XML.
|
|
309
|
+
*/
|
|
310
|
+
export function orderObject(xml, op) {
|
|
311
|
+
const tree = parseDisplayTree(xml);
|
|
312
|
+
const node = findNamed(tree.root, op.object);
|
|
313
|
+
if (node === undefined)
|
|
314
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No object is named ${JSON.stringify(op.object)} in this display.`, 'Call ftview_get to see the object names.');
|
|
315
|
+
const parent = parentOf(tree.root, node);
|
|
316
|
+
if (parent === undefined)
|
|
317
|
+
throw new EngineeringError('UNSUPPORTED_TARGET', 'The display root cannot be reordered.', 'Target an object inside the display.');
|
|
318
|
+
const element = xml.slice(node.start, node.end);
|
|
319
|
+
const without = removeNode(tree, node);
|
|
320
|
+
const next = parseDisplayTree(without);
|
|
321
|
+
const target = op.object;
|
|
322
|
+
const parentName = parent.type === tree.root.type ? undefined : decodeEntities(parent.attributes.name ?? '');
|
|
323
|
+
const container = parentName === undefined ? next.root : findNamed(next.root, parentName);
|
|
324
|
+
if (container === undefined)
|
|
325
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `The parent of ${JSON.stringify(target)} disappeared while reordering.`, 'Reopen the display and retry.');
|
|
326
|
+
return insertChild(next, container, element, op.index);
|
|
327
|
+
}
|
|
@@ -11,6 +11,7 @@ import { type Coverage, type FsService } from '../core.ts';
|
|
|
11
11
|
import { type Finding } from '../checks.ts';
|
|
12
12
|
import { type Application } from '../model.ts';
|
|
13
13
|
import { type EditOperation } from './operations.ts';
|
|
14
|
+
import { type SchemaValidation } from '../schema.ts';
|
|
14
15
|
/** One prepared edit set. */
|
|
15
16
|
export interface PreparedEdit {
|
|
16
17
|
/** Files whose text changed, keyed by export-relative path. */
|
|
@@ -23,6 +24,8 @@ export interface PreparedEdit {
|
|
|
23
24
|
readonly findings: readonly Finding[];
|
|
24
25
|
/** Stable hash over the source hashes and the operations. */
|
|
25
26
|
readonly hash: string;
|
|
27
|
+
/** Vendor-schema validation of the changed and added displays. */
|
|
28
|
+
readonly schema: SchemaValidation;
|
|
26
29
|
}
|
|
27
30
|
/** Coverage for anything derived from an export. */
|
|
28
31
|
export declare const EDIT_COVERAGE: Coverage;
|
|
@@ -11,7 +11,8 @@ import { createHash } from 'node:crypto';
|
|
|
11
11
|
import { EngineeringError } from "../core.js";
|
|
12
12
|
import { findingsOf } from "../checks.js";
|
|
13
13
|
import { parseDisplay, parseTagCsv } from "../model.js";
|
|
14
|
-
import { renameDisplay, setAttribute, upsertTag } from "./operations.js";
|
|
14
|
+
import { addObject, createDisplay, deleteObject, orderObject, renameDisplay, setAttribute, setObject, upsertTag } from "./operations.js";
|
|
15
|
+
import { validateSchema } from "../schema.js";
|
|
15
16
|
/** Coverage for anything derived from an export. */
|
|
16
17
|
export const EDIT_COVERAGE = {
|
|
17
18
|
complete: false,
|
|
@@ -63,6 +64,31 @@ export async function prepareEdit(fs, app, operations, signal) {
|
|
|
63
64
|
texts.set(operation.file, setAttribute(current, operation.object, operation.attribute, operation.value));
|
|
64
65
|
continue;
|
|
65
66
|
}
|
|
67
|
+
if (operation.kind === 'display_create') {
|
|
68
|
+
if (displayNames.has(operation.name) || added.includes(operation.file)) {
|
|
69
|
+
throw new EngineeringError('COMPONENT_EXISTS', `A display named ${JSON.stringify(operation.name)} already exists in this export or draft.`, 'Pass a new display name.');
|
|
70
|
+
}
|
|
71
|
+
if (app.files.some(file => file.file.toLowerCase() === operation.file.toLowerCase())) {
|
|
72
|
+
throw new EngineeringError('COMPONENT_EXISTS', `The export already has a file ${operation.file}.`, 'Pass a different file path.');
|
|
73
|
+
}
|
|
74
|
+
texts.set(operation.file, createDisplay(operation));
|
|
75
|
+
added.push(operation.file);
|
|
76
|
+
displayNames.add(operation.name);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (operation.kind === 'object_add' || operation.kind === 'object_set' || operation.kind === 'object_delete' || operation.kind === 'object_order') {
|
|
80
|
+
const known = displayByFile.has(operation.file) || added.includes(operation.file);
|
|
81
|
+
if (!known) {
|
|
82
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `The draft targets ${operation.file}, which this export no longer contains.`, 'Reopen the export and stage the edit again.');
|
|
83
|
+
}
|
|
84
|
+
const current = texts.get(operation.file) ?? await readText(fs, app.root, operation.file, signal);
|
|
85
|
+
const next = operation.kind === 'object_add' ? addObject(current, operation)
|
|
86
|
+
: operation.kind === 'object_set' ? setObject(current, operation)
|
|
87
|
+
: operation.kind === 'object_delete' ? deleteObject(current, operation)
|
|
88
|
+
: orderObject(current, operation);
|
|
89
|
+
texts.set(operation.file, next);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
66
92
|
if (operation.kind === 'display_add') {
|
|
67
93
|
const source = app.displays.find(display => display.name === operation.copyFrom);
|
|
68
94
|
if (source === undefined) {
|
|
@@ -122,12 +148,20 @@ export async function prepareEdit(fs, app, operations, signal) {
|
|
|
122
148
|
return !changedFiles.has(file);
|
|
123
149
|
});
|
|
124
150
|
const findings = findingsOf({ displays, tags, tagErrors, diagnostics });
|
|
151
|
+
// Validate only the documents this draft writes: the unchanged ones were
|
|
152
|
+
// already read by the session, and re-validating 18 displays per call would
|
|
153
|
+
// make every edit pay for the whole export.
|
|
154
|
+
const changedDisplays = [...texts]
|
|
155
|
+
.filter(([file]) => /\.xml$/iu.test(file) && !/batchimport/iu.test(file))
|
|
156
|
+
.map(([file, text]) => ({ file, xml: text }));
|
|
157
|
+
const schema = await validateSchema(changedDisplays);
|
|
125
158
|
const hashInput = app.files.map(file => `${file.file}:${file.sha256}`).join('\n') + '\n' + JSON.stringify(operations);
|
|
126
159
|
return {
|
|
127
160
|
texts,
|
|
128
161
|
added,
|
|
129
162
|
changed: [...app.files.map(file => file.file), ...added],
|
|
130
163
|
findings,
|
|
164
|
+
schema,
|
|
131
165
|
hash: createHash('sha256').update(hashInput).digest('hex'),
|
|
132
166
|
};
|
|
133
167
|
}
|
|
@@ -7,9 +7,13 @@
|
|
|
7
7
|
*
|
|
8
8
|
* @module dsh-engineering-ftview/edit/tools
|
|
9
9
|
*/
|
|
10
|
-
import { createHash } from 'node:crypto';
|
|
11
|
-
import {
|
|
10
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { EngineeringError, integer, object, optionalInteger, optionalString, requiredString, string, success, tool } from "../core.js";
|
|
12
14
|
import { EDIT_COVERAGE, prepareEdit } from "./prepare.js";
|
|
15
|
+
import { exportApplication } from "../vendor.js";
|
|
16
|
+
import { allMatched, verifyDisplays } from "../handoff.js";
|
|
13
17
|
/** The view handle parameter, described once. */
|
|
14
18
|
const VIEW_ID = string('View handle returned by ftview_open. Omit when exactly one export is open in this chat.');
|
|
15
19
|
/** The draft handle parameter, described once. */
|
|
@@ -27,6 +31,41 @@ function nextValidate(draftId) {
|
|
|
27
31
|
function objectCount(display, name) {
|
|
28
32
|
return display.objects.filter(object => object.name === name).length;
|
|
29
33
|
}
|
|
34
|
+
/** One display by name, with a repairable failure. */
|
|
35
|
+
function displayOf(view, name) {
|
|
36
|
+
const display = view.app.displays.find(candidate => candidate.name === name);
|
|
37
|
+
if (display === undefined) {
|
|
38
|
+
throw new EngineeringError('COMPONENT_NOT_FOUND', `No display is named ${JSON.stringify(name)} in this export.`, 'Call ftview_list with kind "display" to see the display names.', view.app.displays.slice(0, 5).map(candidate => candidate.name));
|
|
39
|
+
}
|
|
40
|
+
return display;
|
|
41
|
+
}
|
|
42
|
+
/** Parse one JSON object argument into string attributes. */
|
|
43
|
+
function jsonObject(value, name) {
|
|
44
|
+
if (value === undefined)
|
|
45
|
+
return {};
|
|
46
|
+
if (typeof value !== 'string')
|
|
47
|
+
throw new EngineeringError('INVALID_ARGUMENT', `${name} must be a JSON object string.`, `Pass ${name} as a JSON object, for example {"height":"40"}.`);
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(value);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
throw new EngineeringError('INVALID_ARGUMENT', `${name} is not valid JSON.`, `Pass ${name} as a JSON object, for example {"height":"40"}.`);
|
|
54
|
+
}
|
|
55
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
56
|
+
throw new EngineeringError('INVALID_ARGUMENT', `${name} must be a JSON object.`, `Pass ${name} as a JSON object, for example {"height":"40"}.`);
|
|
57
|
+
}
|
|
58
|
+
const result = {};
|
|
59
|
+
for (const [key, entry] of Object.entries(parsed)) {
|
|
60
|
+
if (typeof entry === 'number' || typeof entry === 'boolean')
|
|
61
|
+
result[key] = String(entry);
|
|
62
|
+
else if (typeof entry === 'string')
|
|
63
|
+
result[key] = entry;
|
|
64
|
+
else
|
|
65
|
+
throw new EngineeringError('INVALID_ARGUMENT', `${name}.${key} must be a string, number or boolean.`, 'Pass scalar values only.');
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
30
69
|
/**
|
|
31
70
|
* The editing tools.
|
|
32
71
|
*
|
|
@@ -131,9 +170,146 @@ export function editTools(session, drafts, fs, maxOperations) {
|
|
|
131
170
|
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
132
171
|
},
|
|
133
172
|
}),
|
|
173
|
+
tool({
|
|
174
|
+
name: 'ftview_draft_display_create',
|
|
175
|
+
description: 'Stage a brand-new display with a schema-valid skeleton. Nothing is written until ftview_apply.',
|
|
176
|
+
parameters: object({
|
|
177
|
+
view_id: VIEW_ID,
|
|
178
|
+
name: string('New display name, unique in the export. Example: "Arya Overview".'),
|
|
179
|
+
width: integer('Display width in pixels. Example: 800.', { minimum: 1, maximum: 16384 }),
|
|
180
|
+
height: integer('Display height in pixels. Example: 600.', { minimum: 1, maximum: 16384 }),
|
|
181
|
+
display_type: string('Optional display type as Studio writes it, for example "replace" or "onTop".'),
|
|
182
|
+
back_color: string('Optional background colour. Example: "#C6C6C6".'),
|
|
183
|
+
display_number: integer('Optional display number.', { minimum: 0 }),
|
|
184
|
+
file: string('Optional export-relative file path. Defaults to "Displays/<name>.xml".'),
|
|
185
|
+
}, ['name', 'width', 'height']),
|
|
186
|
+
async run(args, exec) {
|
|
187
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
188
|
+
const name = requiredString(args, 'name');
|
|
189
|
+
if (view.app.displays.some(display => display.name === name)) {
|
|
190
|
+
throw new EngineeringError('COMPONENT_EXISTS', `A display named ${JSON.stringify(name)} already exists in this export.`, 'Pass a new display name.');
|
|
191
|
+
}
|
|
192
|
+
const displayType = optionalString(args, 'display_type');
|
|
193
|
+
const backColor = optionalString(args, 'back_color');
|
|
194
|
+
const displayNumber = optionalInteger(args, 'display_number');
|
|
195
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), {
|
|
196
|
+
kind: 'display_create',
|
|
197
|
+
file: optionalString(args, 'file') ?? `Displays/${name}.xml`,
|
|
198
|
+
name,
|
|
199
|
+
width: optionalInteger(args, 'width') ?? 800,
|
|
200
|
+
height: optionalInteger(args, 'height') ?? 600,
|
|
201
|
+
...(displayType === undefined ? {} : { displayType }),
|
|
202
|
+
...(backColor === undefined ? {} : { backColor }),
|
|
203
|
+
...(displayNumber === undefined ? {} : { displayNumber }),
|
|
204
|
+
});
|
|
205
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
206
|
+
},
|
|
207
|
+
}),
|
|
208
|
+
tool({
|
|
209
|
+
name: 'ftview_draft_object_add',
|
|
210
|
+
description: 'Stage one new object inside a display: geometry, attributes and tag connections. Validated against the vendor schema on ftview_validate. Nothing is written until ftview_apply.',
|
|
211
|
+
parameters: object({
|
|
212
|
+
view_id: VIEW_ID,
|
|
213
|
+
display: string('Display name as listed by ftview_list.'),
|
|
214
|
+
type: string('Object element type as the export writes it, e.g. "momentaryButton", "text", "panel", "numericDisplay", "gotoButton".'),
|
|
215
|
+
name: string('New object name, unique inside the display.'),
|
|
216
|
+
attributes_json: string('Object attributes as a JSON object, e.g. {"height":"50","width":"120","left":"20","top":"40"}.'),
|
|
217
|
+
caption: string('Optional caption text (text objects and buttons).'),
|
|
218
|
+
connections_json: string('Optional connections as a JSON object, e.g. {"Value":"SystemRunning"}.'),
|
|
219
|
+
parent: string('Optional group name to add inside. Omit to add at the display root.'),
|
|
220
|
+
index: integer('Optional position among siblings, for z-order.', { minimum: 0 }),
|
|
221
|
+
}, ['display', 'type', 'name']),
|
|
222
|
+
async run(args, exec) {
|
|
223
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
224
|
+
const display = displayOf(view, requiredString(args, 'display'));
|
|
225
|
+
const attributes = { ...jsonObject(args.attributes_json, 'attributes_json') };
|
|
226
|
+
const caption = optionalString(args, 'caption');
|
|
227
|
+
if (caption !== undefined)
|
|
228
|
+
attributes.caption = caption;
|
|
229
|
+
const connections = jsonObject(optionalString(args, 'connections_json'), 'connections_json');
|
|
230
|
+
const parent = optionalString(args, 'parent');
|
|
231
|
+
const index = optionalInteger(args, 'index');
|
|
232
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), {
|
|
233
|
+
kind: 'object_add',
|
|
234
|
+
file: display.file,
|
|
235
|
+
type: requiredString(args, 'type'),
|
|
236
|
+
name: requiredString(args, 'name'),
|
|
237
|
+
attributes,
|
|
238
|
+
...(parent === undefined ? {} : { parent }),
|
|
239
|
+
...(index === undefined ? {} : { index }),
|
|
240
|
+
...(Object.keys(connections).length === 0 ? {} : { connections }),
|
|
241
|
+
});
|
|
242
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
243
|
+
},
|
|
244
|
+
}),
|
|
245
|
+
tool({
|
|
246
|
+
name: 'ftview_draft_object_set',
|
|
247
|
+
description: 'Stage attribute, caption or connection changes on one existing object. Nothing is written until ftview_apply.',
|
|
248
|
+
parameters: object({
|
|
249
|
+
view_id: VIEW_ID,
|
|
250
|
+
display: string('Display name as listed by ftview_list.'),
|
|
251
|
+
object: string('Object name inside the display.'),
|
|
252
|
+
attributes_json: string('Attributes to set as a JSON object, e.g. {"visible":"0","left":"30"}.'),
|
|
253
|
+
caption: string('Optional new caption text.'),
|
|
254
|
+
connections_json: string('Connections to set as a JSON object, e.g. {"Value":"SystemRunning"}.'),
|
|
255
|
+
}, ['display', 'object']),
|
|
256
|
+
async run(args, exec) {
|
|
257
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
258
|
+
const display = displayOf(view, requiredString(args, 'display'));
|
|
259
|
+
const object = requiredString(args, 'object');
|
|
260
|
+
const attributes = jsonObject(args.attributes_json, 'attributes_json');
|
|
261
|
+
const connections = jsonObject(optionalString(args, 'connections_json'), 'connections_json');
|
|
262
|
+
const caption = optionalString(args, 'caption');
|
|
263
|
+
if (Object.keys(attributes).length === 0 && Object.keys(connections).length === 0 && caption === undefined) {
|
|
264
|
+
throw new EngineeringError('INVALID_ARGUMENT', 'No change was requested.', 'Pass attributes_json, connections_json or caption.');
|
|
265
|
+
}
|
|
266
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), {
|
|
267
|
+
kind: 'object_set',
|
|
268
|
+
file: display.file,
|
|
269
|
+
object,
|
|
270
|
+
attributes,
|
|
271
|
+
...(caption === undefined ? {} : { caption }),
|
|
272
|
+
...(Object.keys(connections).length === 0 ? {} : { connections }),
|
|
273
|
+
});
|
|
274
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
275
|
+
},
|
|
276
|
+
}),
|
|
277
|
+
tool({
|
|
278
|
+
name: 'ftview_draft_object_delete',
|
|
279
|
+
description: 'Stage the removal of one object from a display. Nothing is written until ftview_apply.',
|
|
280
|
+
parameters: object({
|
|
281
|
+
view_id: VIEW_ID,
|
|
282
|
+
display: string('Display name as listed by ftview_list.'),
|
|
283
|
+
object: string('Object name to remove.'),
|
|
284
|
+
}, ['display', 'object']),
|
|
285
|
+
async run(args, exec) {
|
|
286
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
287
|
+
const display = displayOf(view, requiredString(args, 'display'));
|
|
288
|
+
const object = requiredString(args, 'object');
|
|
289
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), { kind: 'object_delete', file: display.file, object });
|
|
290
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
tool({
|
|
294
|
+
name: 'ftview_draft_object_order',
|
|
295
|
+
description: 'Stage a z-order move for one object among its siblings. Nothing is written until ftview_apply.',
|
|
296
|
+
parameters: object({
|
|
297
|
+
view_id: VIEW_ID,
|
|
298
|
+
display: string('Display name as listed by ftview_list.'),
|
|
299
|
+
object: string('Object name to move.'),
|
|
300
|
+
index: integer('New position among the object siblings, 0 = back.', { minimum: 0 }),
|
|
301
|
+
}, ['display', 'object', 'index']),
|
|
302
|
+
async run(args, exec) {
|
|
303
|
+
const view = session.require(optionalString(args, 'view_id'), exec);
|
|
304
|
+
const display = displayOf(view, requiredString(args, 'display'));
|
|
305
|
+
const object = requiredString(args, 'object');
|
|
306
|
+
const { draft } = stage(exec, optionalString(args, 'view_id'), { kind: 'object_order', file: display.file, object, index: optionalInteger(args, 'index') ?? 0 });
|
|
307
|
+
return staged(draft, draft.operations[draft.operations.length - 1]);
|
|
308
|
+
},
|
|
309
|
+
}),
|
|
134
310
|
tool({
|
|
135
311
|
name: 'ftview_validate',
|
|
136
|
-
description: 'Prepare the staged edits: apply them to a re-parsed copy,
|
|
312
|
+
description: 'Prepare the staged edits: apply them to a re-parsed copy, validate the changed displays against the installed Gfx-ME schema, and report the changed files and findings. Writes nothing.',
|
|
137
313
|
parameters: object({ draft_id: DRAFT_ID }, []),
|
|
138
314
|
async run(args, exec) {
|
|
139
315
|
const draft = drafts.get(exec, optionalString(args, 'draft_id'));
|
|
@@ -142,16 +318,47 @@ export function editTools(session, drafts, fs, maxOperations) {
|
|
|
142
318
|
const byRule = {};
|
|
143
319
|
for (const finding of prepared.findings)
|
|
144
320
|
byRule[finding.rule] = (byRule[finding.rule] ?? 0) + 1;
|
|
145
|
-
|
|
321
|
+
const schemaErrors = prepared.schema.findings.filter(finding => finding.error);
|
|
322
|
+
return success(`${draft.operations.length} operation(s); ${prepared.texts.size} file(s) change; ${prepared.findings.length} finding(s); schema ${prepared.schema.ran ? `ran (${schemaErrors.length} error(s))` : 'unavailable'}.`, {
|
|
146
323
|
draft_id: draft.id,
|
|
147
324
|
draft_sha256: prepared.hash,
|
|
148
325
|
changed_files: [...prepared.texts.keys()],
|
|
149
326
|
added: prepared.added,
|
|
150
327
|
findings: prepared.findings,
|
|
151
328
|
by_rule: byRule,
|
|
329
|
+
schema: prepared.schema,
|
|
152
330
|
}, [{ tool: 'ftview_apply', args: { draft_id: draft.id }, why: 'Write the prepared export to a new folder.' }], EDIT_COVERAGE);
|
|
153
331
|
},
|
|
154
332
|
}),
|
|
333
|
+
tool({
|
|
334
|
+
name: 'ftview_verify_import',
|
|
335
|
+
description: 'Export the application again through the vendor engine and compare it object by object with the staged draft, proving whether the import landed. Writes only a temporary export folder.',
|
|
336
|
+
parameters: object({
|
|
337
|
+
view_id: VIEW_ID,
|
|
338
|
+
draft_id: DRAFT_ID,
|
|
339
|
+
application: string('Application name as ftview_apps lists it. The same application the package was imported into.'),
|
|
340
|
+
}, ['application']),
|
|
341
|
+
async run(args, exec) {
|
|
342
|
+
const draft = drafts.get(exec, optionalString(args, 'draft_id'));
|
|
343
|
+
const view = session.require(draft.viewId, exec);
|
|
344
|
+
const prepared = await prepareEdit(fs, view.app, draft.operations, exec.signal);
|
|
345
|
+
const expected = new Map([...prepared.texts].filter(([file]) => /\.xml$/iu.test(file) && !/batchimport/iu.test(file)));
|
|
346
|
+
if (expected.size === 0) {
|
|
347
|
+
throw new EngineeringError('NOTHING_TO_VERIFY', 'This draft changes no display, so there is nothing to compare.', 'Stage a display edit first, import it in Studio, then run this again.');
|
|
348
|
+
}
|
|
349
|
+
const root = join(tmpdir(), `ftview-verify-${randomUUID()}`);
|
|
350
|
+
const exported = await exportApplication(requiredString(args, 'application'), root);
|
|
351
|
+
if (exported.error !== undefined) {
|
|
352
|
+
throw new EngineeringError('VENDOR_EXPORT_FAILED', exported.error, 'Close the application in Studio if it is open, then retry.');
|
|
353
|
+
}
|
|
354
|
+
const results = await verifyDisplays(fs, root, expected);
|
|
355
|
+
const matched = results.filter(result => result.matched).length;
|
|
356
|
+
const differences = results.flatMap(result => result.differences.map(difference => ({ file: result.file, ...difference })));
|
|
357
|
+
return success(`${matched}/${results.length} display(s) match the draft${allMatched(results) ? '' : `; ${differences.length} difference(s)`}. Export kept at ${root}.`, { application: requiredString(args, 'application'), export_root: root, displays: results, differences, matched: allMatched(results) }, matched === results.length
|
|
358
|
+
? [{ tool: 'ftview_draft_display_create', why: 'Author the next screen against the verified export.' }]
|
|
359
|
+
: [{ tool: 'ftview_validate', args: { draft_id: draft.id }, why: 'Re-read the draft findings before correcting the difference.' }], EDIT_COVERAGE);
|
|
360
|
+
},
|
|
361
|
+
}),
|
|
155
362
|
tool({
|
|
156
363
|
name: 'ftview_apply',
|
|
157
364
|
description: 'Write the prepared edits to a NEW export folder: changed files get the edits, other text exports are copied verbatim. The source folder is never modified.',
|
|
@@ -163,6 +370,10 @@ export function editTools(session, drafts, fs, maxOperations) {
|
|
|
163
370
|
const draft = drafts.get(exec, optionalString(args, 'draft_id'));
|
|
164
371
|
const view = session.require(draft.viewId, exec);
|
|
165
372
|
const prepared = await prepareEdit(fs, view.app, draft.operations, exec.signal);
|
|
373
|
+
const schemaErrors = prepared.schema.findings.filter(finding => finding.error);
|
|
374
|
+
if (schemaErrors.length > 0) {
|
|
375
|
+
throw new EngineeringError('SCHEMA_INVALID', `${schemaErrors.length} schema error(s); nothing was written. First: ${schemaErrors[0]?.file}: ${schemaErrors[0]?.message}`, 'Fix the offending attributes or elements and run ftview_validate again; apply refuses a document the vendor schema rejects.', schemaErrors.slice(0, 5).map(finding => `${finding.file}: ${finding.message}`));
|
|
376
|
+
}
|
|
166
377
|
const outputRoot = await fs.resolve(requiredString(args, 'output_root'), {
|
|
167
378
|
...(exec.cwd === undefined ? {} : { cwd: exec.cwd }),
|
|
168
379
|
...(exec.signal === undefined ? {} : { signal: exec.signal }),
|
|
@@ -190,8 +401,41 @@ export function editTools(session, drafts, fs, maxOperations) {
|
|
|
190
401
|
await fs.writeText(`${outputRoot}/${file}`, text, { kind: 'createIfAbsent' }, exec.signal);
|
|
191
402
|
written += 1;
|
|
192
403
|
}
|
|
404
|
+
// The vendor's own import file: Studio's Batch Import reads this list
|
|
405
|
+
// from the Displays folder. Writing it here is what turns a folder of
|
|
406
|
+
// XML into a one-step import.
|
|
407
|
+
const changedDisplays = [...prepared.texts.keys()]
|
|
408
|
+
.filter(file => /^Displays\/.+\.xml$/iu.test(file) && !/batchimport/iu.test(file));
|
|
409
|
+
let manifest;
|
|
410
|
+
if (changedDisplays.length > 0) {
|
|
411
|
+
const entries = changedDisplays
|
|
412
|
+
.map(file => ` <import importFile="${file.replace(/^Displays\//u, '').replaceAll('&', '&').replaceAll('"', '"')}"/>`)
|
|
413
|
+
.join('\n');
|
|
414
|
+
manifest = `Displays/BatchImport_${view.app.name}.xml`;
|
|
415
|
+
await fs.writeText(`${outputRoot}/${manifest}`, `<?xml version="1.0" encoding="UTF-8"?>\n<gfxImport>\n${entries}\n</gfxImport>\n`, { kind: 'createIfAbsent' }, exec.signal);
|
|
416
|
+
written += 1;
|
|
417
|
+
const steps = [
|
|
418
|
+
`# Import ${view.app.name}`,
|
|
419
|
+
'',
|
|
420
|
+
`Open FactoryTalk View Studio → Application Manager, open **${view.app.name}**, and import the displays listed in \`Displays/BatchImport_${view.app.name}.xml\` (${changedDisplays.length} file(s)).`,
|
|
421
|
+
'',
|
|
422
|
+
'Then:',
|
|
423
|
+
'1. Verify each display opens without warnings.',
|
|
424
|
+
'2. Create the runtime application (`.mer`).',
|
|
425
|
+
'3. Run `ftview_verify_import` with the draft id to prove the import matches this draft.',
|
|
426
|
+
'',
|
|
427
|
+
'Changed displays:',
|
|
428
|
+
...changedDisplays.map(file => `- ${file}`),
|
|
429
|
+
'',
|
|
430
|
+
].join('\n');
|
|
431
|
+
await fs.writeText(`${outputRoot}/IMPORT.md`, steps, { kind: 'createIfAbsent' }, exec.signal);
|
|
432
|
+
written += 1;
|
|
433
|
+
}
|
|
193
434
|
drafts.remove(exec, draft.id);
|
|
194
|
-
return success(`Wrote ${written} file(s) to ${outputRoot}.`, { output_root: outputRoot, draft_id: draft.id, files_written: written, changed_files: [...prepared.texts.keys()]
|
|
435
|
+
return success(`Wrote ${written} file(s) to ${outputRoot}${manifest === undefined ? '' : `; import manifest ${manifest}`}.`, { output_root: outputRoot, draft_id: draft.id, files_written: written, changed_files: [...prepared.texts.keys()], ...(manifest === undefined ? {} : { manifest }), schema: prepared.schema }, [
|
|
436
|
+
{ tool: 'ftview_open', args: { root: outputRoot }, why: 'Reopen the written export and check it.' },
|
|
437
|
+
{ tool: 'ftview_verify_import', args: { application: view.app.name }, why: 'After Studio imports it, prove the import matches this draft.' },
|
|
438
|
+
], APPLY_COVERAGE);
|
|
195
439
|
},
|
|
196
440
|
}),
|
|
197
441
|
];
|