@aryacode/dsh-engineering 0.7.0 → 0.8.1

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.
@@ -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('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
@@ -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 { EngineeringError, object, optionalString, requiredString, string, success, tool } from "../core.js";
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, report the changed files and the new structural findings. Writes nothing.',
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
- return success(`${draft.operations.length} operation(s); ${prepared.texts.size} file(s) change; ${prepared.findings.length} finding(s).`, {
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('&', '&amp;').replaceAll('"', '&quot;')}"/>`)
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()] }, [{ tool: 'ftview_open', args: { root: outputRoot }, why: 'Reopen the written export and check it.' }], APPLY_COVERAGE);
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
  ];