archgraph-argo 0.11.0 → 0.12.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/argo/defaults/EA-model-template.qea +0 -0
- package/argo/scripts/argo-mcp-server.js +56 -3
- package/argo/scripts/ea-qea-sync-lib.js +688 -0
- package/argo/scripts/ea-qea-sync.js +109 -0
- package/argo/scripts/systemarchitecture-mcp-server.js +73 -0
- package/install-argo.ps1 +23 -89
- package/package.json +3 -5
- package/scripts/ea-layout-store.js +0 -242
- package/scripts/ea-web-service.js +0 -1349
- package/web/app.js +0 -398
- package/web/index.html +0 -86
- package/web/style.css +0 -251
- package/web/vendor/g6.min.js +0 -68
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// EA .qea (SQLite) direct projection library — WP2791.
|
|
4
|
+
// Zero third-party deps: node:sqlite (DatabaseSync, Node >=22/24), node:crypto, node:fs.
|
|
5
|
+
// Mirrors the ArchiMate->EA mapping conventions of eatool/EA-jsscript/import-from-kg.js
|
|
6
|
+
// (Object_Type base + Stereotype archimate name + Alias/ea_guid anchors + schema_view_id
|
|
7
|
+
// StyleEx token) while writing through SQLite directly (no EA COM required).
|
|
8
|
+
//
|
|
9
|
+
// Concurrency model:
|
|
10
|
+
// - PRAGMA busy_timeout = 15000 set on every connection; write phases wrapped in short
|
|
11
|
+
// transactions with a bounded busy retry. Keep EA's own SQLite handle open is fine —
|
|
12
|
+
// SQLite only locks during active transactions, an idle open connection does not block.
|
|
13
|
+
// - Rows are matched by Alias (schema id) / deterministic ea_guid, update-in-place only;
|
|
14
|
+
// existing t_diagramobjects/t_diagramlinks geometry is NEVER updated or deleted, only
|
|
15
|
+
// missing members are INSERTed.
|
|
16
|
+
|
|
17
|
+
const { DatabaseSync } = require('node:sqlite');
|
|
18
|
+
const DEBUG = !!process.env.EA_QEA_DEBUG;
|
|
19
|
+
const crypto = require('node:crypto');
|
|
20
|
+
|
|
21
|
+
const SYNC_PACKAGE_NAME = 'ArchGraph Sync';
|
|
22
|
+
const DIAGRAM_TYPE = 'Logical';
|
|
23
|
+
const META_TABLE = 'kg_sync_meta'; // {kind,key,sha,payload} — Node export/reconcile store
|
|
24
|
+
const BUSY_TIMEOUT_MS = 15000;
|
|
25
|
+
const CHUNK = 200;
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// ArchiMate -> EA mapping (mirrors import-from-kg.js)
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
function normalizeName(value) {
|
|
31
|
+
const text = String(value === null || value === undefined ? '' : value);
|
|
32
|
+
return text.replace(/^ArchiMate[_\s-]*/i, '');
|
|
33
|
+
}
|
|
34
|
+
function alnum(value) {
|
|
35
|
+
return String(value === null || value === undefined ? '' : value)
|
|
36
|
+
.replace(/&/g, '')
|
|
37
|
+
.replace(/[^A-Za-z0-9]/g, '');
|
|
38
|
+
}
|
|
39
|
+
function elementStereotype(type) {
|
|
40
|
+
const norm = alnum(normalizeName(type));
|
|
41
|
+
if (norm === '') { return ''; }
|
|
42
|
+
if (norm === 'SystemSoftware') { return 'ArchiMate_SystemSoftware'; }
|
|
43
|
+
if (norm === 'Constraint') { return 'ArchiMate_Constraint'; }
|
|
44
|
+
return norm;
|
|
45
|
+
}
|
|
46
|
+
const ACTIVITY_ALNUM = new Set([
|
|
47
|
+
'BusinessEvent', 'BusinessProcess', 'BusinessFunction', 'BusinessInteraction',
|
|
48
|
+
'BusinessService', 'ApplicationEvent', 'ApplicationProcess', 'ApplicationFunction',
|
|
49
|
+
'ApplicationInteraction', 'ApplicationService', 'TechnologyEvent',
|
|
50
|
+
'TechnologyProcess', 'TechnologyFunction', 'TechnologyInteraction',
|
|
51
|
+
'TechnologyService', 'ValueStream',
|
|
52
|
+
]);
|
|
53
|
+
function elementObjectType(type) {
|
|
54
|
+
const norm = alnum(normalizeName(type));
|
|
55
|
+
if (norm === 'ApplicationComponent') { return 'Component'; }
|
|
56
|
+
if (ACTIVITY_ALNUM.has(norm)) { return 'Activity'; }
|
|
57
|
+
if (norm === 'Junction' || norm === 'AndJunction' || norm === 'OrJunction') { return 'StateNode'; }
|
|
58
|
+
return 'Class';
|
|
59
|
+
}
|
|
60
|
+
function canonicalArchimateType(type) {
|
|
61
|
+
const norm = alnum(normalizeName(type));
|
|
62
|
+
return norm; // full ArchiMate name e.g. BusinessActor is carried by Stereotype column
|
|
63
|
+
}
|
|
64
|
+
function relStereotype(type) {
|
|
65
|
+
return alnum(normalizeName(type));
|
|
66
|
+
}
|
|
67
|
+
function relationshipMap(type) {
|
|
68
|
+
const norm = alnum(normalizeName(type));
|
|
69
|
+
const meta = { connectorType: 'Association', directed: false };
|
|
70
|
+
switch (norm) {
|
|
71
|
+
case 'Composition':
|
|
72
|
+
case 'Aggregation':
|
|
73
|
+
meta.connectorType = 'Association';
|
|
74
|
+
break;
|
|
75
|
+
case 'Specialization':
|
|
76
|
+
meta.connectorType = 'Generalization';
|
|
77
|
+
meta.directed = true;
|
|
78
|
+
break;
|
|
79
|
+
case 'Realization':
|
|
80
|
+
case 'Access':
|
|
81
|
+
meta.connectorType = 'Dependency';
|
|
82
|
+
meta.directed = true;
|
|
83
|
+
break;
|
|
84
|
+
case 'Serving':
|
|
85
|
+
case 'Assignment':
|
|
86
|
+
meta.connectorType = 'Association';
|
|
87
|
+
meta.directed = true;
|
|
88
|
+
break;
|
|
89
|
+
case 'Association':
|
|
90
|
+
meta.connectorType = 'Association';
|
|
91
|
+
break;
|
|
92
|
+
case 'Triggering':
|
|
93
|
+
case 'Flow':
|
|
94
|
+
case 'Influence':
|
|
95
|
+
meta.connectorType = 'ControlFlow';
|
|
96
|
+
meta.directed = true;
|
|
97
|
+
break;
|
|
98
|
+
default:
|
|
99
|
+
meta.connectorType = 'Association';
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
return meta;
|
|
103
|
+
}
|
|
104
|
+
function safeName(name, fallbackId) {
|
|
105
|
+
const n = String(name === null || name === undefined ? '' : name).trim();
|
|
106
|
+
return n !== '' ? n : String(fallbackId === null || fallbackId === undefined ? '' : fallbackId);
|
|
107
|
+
}
|
|
108
|
+
function deterministicGuid(seed) {
|
|
109
|
+
const h = crypto.createHash('sha1').update(String(seed)).digest('hex');
|
|
110
|
+
return '{' + h.slice(0, 8) + '-' + h.slice(8, 12) + '-' + h.slice(12, 16) + '-' + h.slice(16, 20) + '-' + h.slice(20, 32) + '}';
|
|
111
|
+
}
|
|
112
|
+
function sha1(text) {
|
|
113
|
+
return crypto.createHash('sha1').update(String(text)).digest('hex');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// sqlite helpers
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
function openQea(file) {
|
|
120
|
+
const db = new DatabaseSync(file);
|
|
121
|
+
db.exec('PRAGMA busy_timeout=' + BUSY_TIMEOUT_MS);
|
|
122
|
+
db.exec('PRAGMA foreign_keys=OFF');
|
|
123
|
+
return db;
|
|
124
|
+
}
|
|
125
|
+
function columnNames(db, table) {
|
|
126
|
+
const rows = db.prepare('PRAGMA table_info(' + table + ')').all();
|
|
127
|
+
return rows.map((r) => r.name);
|
|
128
|
+
}
|
|
129
|
+
function ensureMetaTable(db) {
|
|
130
|
+
db.exec('CREATE TABLE IF NOT EXISTS ' + META_TABLE + ' (kind TEXT NOT NULL, key TEXT NOT NULL, sha TEXT, payload TEXT, PRIMARY KEY(kind,key))');
|
|
131
|
+
}
|
|
132
|
+
// multi-row INSERT with positional placeholders, chunked
|
|
133
|
+
function insertMany(db, table, columns, rows) {
|
|
134
|
+
let inserted = 0;
|
|
135
|
+
if (!rows || rows.length === 0) { return 0; }
|
|
136
|
+
for (let i = 0; i < rows.length; i += CHUNK) {
|
|
137
|
+
const chunk = rows.slice(i, i + CHUNK);
|
|
138
|
+
const colSql = columns.join(', ');
|
|
139
|
+
const valueSql = chunk.map(() => '(' + columns.map(() => '?').join(', ') + ')').join(', ');
|
|
140
|
+
const sql = 'INSERT INTO ' + table + ' (' + colSql + ') VALUES ' + valueSql;
|
|
141
|
+
const params = [];
|
|
142
|
+
for (const r of chunk) { for (const c of columns) { params.push(r[c]); } }
|
|
143
|
+
db.prepare(sql).run(...params);
|
|
144
|
+
inserted += chunk.length;
|
|
145
|
+
}
|
|
146
|
+
return inserted;
|
|
147
|
+
}
|
|
148
|
+
function updateRow(db, table, setColumns, whereColumn, id, values) {
|
|
149
|
+
const setSql = setColumns.map((c) => c + '=?').join(', ');
|
|
150
|
+
db.prepare('UPDATE ' + table + ' SET ' + setSql + ' WHERE ' + whereColumn + '=?')
|
|
151
|
+
.run(...setColumns.map((c) => values[c]), id);
|
|
152
|
+
}
|
|
153
|
+
function upsertMeta(db, kind, key, node) {
|
|
154
|
+
const payload = JSON.stringify(node);
|
|
155
|
+
const sh = sha1(payload);
|
|
156
|
+
db.prepare('INSERT INTO ' + META_TABLE + ' (kind,key,sha,payload) VALUES (?,?,?,?) ON CONFLICT(kind,key) DO UPDATE SET sha=excluded.sha, payload=excluded.payload')
|
|
157
|
+
.run(kind, String(key), sh, payload);
|
|
158
|
+
return sh;
|
|
159
|
+
}
|
|
160
|
+
function readMetaKind(db, kind) {
|
|
161
|
+
const rows = db.prepare('SELECT key, payload FROM ' + META_TABLE + ' WHERE kind=? ORDER BY key').all(kind);
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const r of rows) { try { out.push(JSON.parse(r.payload)); } catch { /* skip corrupt */ } }
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
// mark checkpoints
|
|
167
|
+
function nowMs() { return Date.now(); }
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Package anchoring
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
function resolveSyncPackage(db, dryRun) {
|
|
173
|
+
let roots = db.prepare('SELECT Package_ID FROM t_package WHERE Parent_ID=0 ORDER BY Package_ID').all();
|
|
174
|
+
if (roots.length === 0) { roots = db.prepare('SELECT Package_ID FROM t_package ORDER BY Package_ID LIMIT 1').all(); }
|
|
175
|
+
const rootId = Number(roots[0].Package_ID);
|
|
176
|
+
let existing = db.prepare('SELECT Package_ID, Name FROM t_package WHERE Parent_ID=? AND Name=?').get(rootId, SYNC_PACKAGE_NAME);
|
|
177
|
+
if (existing) { return { rootId, packageId: Number(existing.Package_ID), created: false }; }
|
|
178
|
+
if (dryRun) { return { rootId, packageId: 0, created: true }; }
|
|
179
|
+
const guid = deterministicGuid('pkg:' + SYNC_PACKAGE_NAME);
|
|
180
|
+
const r = db.prepare('INSERT INTO t_package (Name, Parent_ID, ea_guid, Notes) VALUES (?,?,?,?)').run(SYNC_PACKAGE_NAME, rootId, guid, '');
|
|
181
|
+
return { rootId, packageId: Number(r.lastInsertRowid), created: true };
|
|
182
|
+
}
|
|
183
|
+
function parseStyleToken(styleEx, key) {
|
|
184
|
+
const text = String(styleEx === null || styleEx === undefined ? '' : styleEx);
|
|
185
|
+
const re = new RegExp('(^|;|\\s)' + key + '=([^;]*)', 'i');
|
|
186
|
+
const m = re.exec(text);
|
|
187
|
+
return m ? m[2] : '';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Core sync
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// opts: { dryRun, allowDelete, snapshotDir }
|
|
194
|
+
function syncGraphToQea(graph, qeaPath, opts) {
|
|
195
|
+
const o = opts || {};
|
|
196
|
+
const stages = {};
|
|
197
|
+
const stats = {
|
|
198
|
+
added: { elements: 0, relationships: 0, diagrams: 0, diagramObjects: 0, diagramLinks: 0 },
|
|
199
|
+
updated: { elements: 0, relationships: 0, diagrams: 0 },
|
|
200
|
+
skipped: { elements: 0, relationships: 0, diagrams: 0 },
|
|
201
|
+
deleteCandidates: 0, deleted: 0,
|
|
202
|
+
};
|
|
203
|
+
const t0 = nowMs();
|
|
204
|
+
const db = openQea(qeaPath);
|
|
205
|
+
try {
|
|
206
|
+
ensureMetaTable(db);
|
|
207
|
+
if (!o.dryRun) { db.exec('BEGIN IMMEDIATE'); }
|
|
208
|
+
stages.package = nowMs();
|
|
209
|
+
|
|
210
|
+
const syncPkg = resolveSyncPackage(db, o.dryRun);
|
|
211
|
+
const syncId = syncPkg.packageId;
|
|
212
|
+
stats.syncPackageId = syncId;
|
|
213
|
+
|
|
214
|
+
// --- elements ---------------------------------------------------------
|
|
215
|
+
const elemById = new Map();
|
|
216
|
+
for (const e of graph.elements || []) { if (e && e.id !== undefined && e.id !== null) { elemById.set(String(e.id), e); } }
|
|
217
|
+
const existingElems = db.prepare(
|
|
218
|
+
'SELECT Object_ID, Alias, ea_guid, Object_Type, Name, Stereotype, Note, Status, Package_ID, ParentID FROM t_object WHERE Package_ID=?').all(syncId);
|
|
219
|
+
const elemByAlias = new Map();
|
|
220
|
+
const elemByGuid = new Map();
|
|
221
|
+
for (const row of existingElems) {
|
|
222
|
+
if (row.Alias) { elemByAlias.set(String(row.Alias), row); }
|
|
223
|
+
if (row.ea_guid) { elemByGuid.set(String(row.ea_guid), row); }
|
|
224
|
+
}
|
|
225
|
+
const newElems = [];
|
|
226
|
+
const parentOf = {};
|
|
227
|
+
for (const e of graph.elements || []) {
|
|
228
|
+
if (!e || e.id === undefined || e.id === null) { continue; }
|
|
229
|
+
const alias = String(e.id);
|
|
230
|
+
const guid = deterministicGuid('el:' + alias);
|
|
231
|
+
const existing = elemByAlias.get(alias) || elemByGuid.get(guid) || null;
|
|
232
|
+
const parentId = e.parent !== undefined && e.parent !== null && String(e.parent) !== '0' && elemById.has(String(e.parent)) ? parentOf[String(e.parent)] || 0 : 0;
|
|
233
|
+
const intended = {
|
|
234
|
+
Object_Type: elementObjectType(e.type),
|
|
235
|
+
Name: safeName(e.name, alias),
|
|
236
|
+
Stereotype: elementStereotype(e.type),
|
|
237
|
+
Note: String(e.description === null || e.description === undefined ? '' : e.description),
|
|
238
|
+
Status: String(e.status === null || e.status === undefined ? 'Proposed' : e.status),
|
|
239
|
+
ParentID: parentId || 0,
|
|
240
|
+
};
|
|
241
|
+
if (existing) {
|
|
242
|
+
const changed = intended.Object_Type !== existing.Object_Type || intended.Name !== existing.Name ||
|
|
243
|
+
(intended.Stereotype || '') !== (existing.Stereotype || '') || intended.Note !== (existing.Note || '') ||
|
|
244
|
+
(intended.Status || '') !== (existing.Status || '') || Number(intended.ParentID) !== Number(existing.ParentID || 0);
|
|
245
|
+
if (changed && !o.dryRun) {
|
|
246
|
+
updateRow(db, 't_object', ['Name', 'Stereotype', 'Note', 'Status', 'ParentID'], 'Object_ID', Number(existing.Object_ID), intended);
|
|
247
|
+
}
|
|
248
|
+
stats[changed ? 'updated' : 'skipped'].elements++;
|
|
249
|
+
parentOf[alias] = Number(existing.Object_ID);
|
|
250
|
+
} else {
|
|
251
|
+
newElems.push({ ...intended, Alias: alias, ea_guid: guid, Package_ID: syncId });
|
|
252
|
+
stats.added.elements++;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// batch insert new elements (ParentID refined below) then read ids
|
|
256
|
+
if (newElems.length > 0) {
|
|
257
|
+
if (!o.dryRun) {
|
|
258
|
+
insertMany(db, 't_object', ['Object_Type', 'Name', 'Stereotype', 'Note', 'Status', 'Alias', 'ea_guid', 'Package_ID', 'ParentID'], newElems);
|
|
259
|
+
}
|
|
260
|
+
for (const row of newElems) { parentOf[row.Alias] = row.Alias; } // placeholder; resolved by readback
|
|
261
|
+
}
|
|
262
|
+
// resolve real Object_ID for new elements + parents
|
|
263
|
+
const aliasToId = new Map();
|
|
264
|
+
if (!o.dryRun) {
|
|
265
|
+
const aliases = newElems.map((r) => r.Alias);
|
|
266
|
+
const readback = [];
|
|
267
|
+
for (let i = 0; i < aliases.length; i += 200) {
|
|
268
|
+
const part = aliases.slice(i, i + 200);
|
|
269
|
+
const marks = part.map(() => '?').join(',');
|
|
270
|
+
const rows = db.prepare('SELECT Object_ID, Alias FROM t_object WHERE Alias IN (' + marks + ')').all(...part);
|
|
271
|
+
readback.push(...rows);
|
|
272
|
+
}
|
|
273
|
+
for (const r of readback) { aliasToId.set(String(r.Alias), Number(r.Object_ID)); }
|
|
274
|
+
} else {
|
|
275
|
+
for (const r of newElems) { aliasToId.set(r.Alias, -1); }
|
|
276
|
+
}
|
|
277
|
+
// second pass: set ParentID where the parent is a new element
|
|
278
|
+
if (newElems.length > 0 && !o.dryRun) {
|
|
279
|
+
for (const e of graph.elements || []) {
|
|
280
|
+
if (!e || e.id === undefined || e.id === null) { continue; }
|
|
281
|
+
const alias = String(e.id);
|
|
282
|
+
const pid = aliasToId.get(alias);
|
|
283
|
+
if (pid === undefined) { continue; }
|
|
284
|
+
const parentNode = e.parent !== undefined && e.parent !== null && elemById.get(String(e.parent)) ? elemById.get(String(e.parent)) : null;
|
|
285
|
+
if (!parentNode) { continue; }
|
|
286
|
+
const parentRowId = (function () {
|
|
287
|
+
const a = String(e.parent);
|
|
288
|
+
const ex = elemByAlias.get(a) || elemByGuid.get(deterministicGuid('el:' + a));
|
|
289
|
+
if (ex) { return Number(ex.Object_ID); }
|
|
290
|
+
return aliasToId.get(a);
|
|
291
|
+
})();
|
|
292
|
+
if (parentRowId && Number(parentRowId) !== pid) {
|
|
293
|
+
db.prepare('UPDATE t_object SET ParentID=? WHERE Object_ID=?').run(Number(parentRowId), pid);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
stages.elements = nowMs();
|
|
298
|
+
|
|
299
|
+
// element anchors + meta (idempotent, fingerprint-skipped)
|
|
300
|
+
let tagStats = { propsNew: 0, propsSkip: 0 };
|
|
301
|
+
if (!o.dryRun) {
|
|
302
|
+
// minimal EA-facing anchors for compatibility with the legacy object-model exporter
|
|
303
|
+
const anchorRows = [];
|
|
304
|
+
const elemIdByAliasFinal = new Map();
|
|
305
|
+
for (const ex of existingElems) { elemIdByAliasFinal.set(String(ex.Alias), Number(ex.Object_ID)); }
|
|
306
|
+
for (const [a, id] of aliasToId) { elemIdByAliasFinal.set(a, id); }
|
|
307
|
+
const toTag = [];
|
|
308
|
+
for (const e of graph.elements || []) {
|
|
309
|
+
if (!e || e.id === undefined || e.id === null) { continue; }
|
|
310
|
+
const id = elemIdByAliasFinal.get(String(e.id));
|
|
311
|
+
if (id === undefined) { continue; }
|
|
312
|
+
toTag.push({ id: Number(id), e });
|
|
313
|
+
}
|
|
314
|
+
const metaNew = [];
|
|
315
|
+
const propsToWrite = [];
|
|
316
|
+
for (const t of toTag) {
|
|
317
|
+
const sh = upsertMeta(db, 'element', t.e.id, t.e);
|
|
318
|
+
metaNew.push(1);
|
|
319
|
+
propsToWrite.push([t.id, 'schema_id', t.e.id]);
|
|
320
|
+
propsToWrite.push([t.id, 'archimate_type', canonicalArchimateType(t.e.type)]);
|
|
321
|
+
}
|
|
322
|
+
if (propsToWrite.length > 0) {
|
|
323
|
+
const existingProps = new Set();
|
|
324
|
+
for (let i = 0; i < propsToWrite.length; i += 200) {
|
|
325
|
+
const part = propsToWrite.slice(i, i + 200);
|
|
326
|
+
const ids = new Set(part.map((p) => p[0]));
|
|
327
|
+
const marks = Array.from(ids).map(() => '?').join(',');
|
|
328
|
+
const rows = db.prepare('SELECT Object_ID, Property FROM t_objectproperties WHERE Object_ID IN (' + marks + ')').all(...Array.from(ids));
|
|
329
|
+
for (const r of rows) { existingProps.add(Number(r.Object_ID) + '|' + r.Property); }
|
|
330
|
+
}
|
|
331
|
+
const newProps = propsToWrite.filter((p) => !existingProps.has(p[0] + '|' + p[1]));
|
|
332
|
+
if (newProps.length > 0) {
|
|
333
|
+
insertMany(db, 't_objectproperties', ['Object_ID', 'Property', 'Value', 'Notes'], newProps.map((p) => ({ Object_ID: p[0], Property: p[1], Value: p[2], Notes: '' })));
|
|
334
|
+
tagStats.propsNew += newProps.length;
|
|
335
|
+
}
|
|
336
|
+
tagStats.propsSkip = propsToWrite.length - newProps.length;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
stages.elemTags = nowMs();
|
|
340
|
+
|
|
341
|
+
// --- relationships -----------------------------------------------------
|
|
342
|
+
const elemIdByAliasAll = new Map();
|
|
343
|
+
for (const ex of existingElems) { if (ex.Alias) { elemIdByAliasAll.set(String(ex.Alias), Number(ex.Object_ID)); } }
|
|
344
|
+
if (!o.dryRun) { for (const [a, id] of aliasToId) { elemIdByAliasAll.set(a, id); } }
|
|
345
|
+
const relByGuid = new Map();
|
|
346
|
+
const existingRels = db.prepare('SELECT Connector_ID, ea_guid, Name, Connector_Type, Stereotype, Notes, Direction, Start_Object_ID, End_Object_ID FROM t_connector').all();
|
|
347
|
+
for (const r of existingRels) { if (r.ea_guid) { relByGuid.set(String(r.ea_guid), r); } }
|
|
348
|
+
const newRels = [];
|
|
349
|
+
for (const rel of graph.relationships || []) {
|
|
350
|
+
if (!rel || rel.id === undefined || rel.id === null) { continue; }
|
|
351
|
+
const alias = String(rel.id);
|
|
352
|
+
const guid = deterministicGuid('rel:' + alias);
|
|
353
|
+
const start = elemIdByAliasAll.get(String(rel.source_id));
|
|
354
|
+
const end = elemIdByAliasAll.get(String(rel.target_id));
|
|
355
|
+
if (start === undefined || end === undefined) { continue; }
|
|
356
|
+
const map = relationshipMap(rel.type);
|
|
357
|
+
const direction = map.directed ? 'Source -> Destination' : '';
|
|
358
|
+
const existing = relByGuid.get(guid);
|
|
359
|
+
const intended = {
|
|
360
|
+
Name: safeName(rel.name, alias),
|
|
361
|
+
Connector_Type: map.connectorType,
|
|
362
|
+
Stereotype: relStereotype(rel.type),
|
|
363
|
+
Notes: String(rel.description === null || rel.description === undefined ? '' : rel.description),
|
|
364
|
+
Direction: direction,
|
|
365
|
+
Start_Object_ID: Number(start),
|
|
366
|
+
End_Object_ID: Number(end),
|
|
367
|
+
};
|
|
368
|
+
if (existing) {
|
|
369
|
+
const changed = intended.Name !== (existing.Name || '') || (intended.Connector_Type || '') !== (existing.Connector_Type || '') ||
|
|
370
|
+
(intended.Stereotype || '') !== (existing.Stereotype || '') || intended.Notes !== (existing.Notes || '') ||
|
|
371
|
+
intended.Direction !== (existing.Direction || '') || Number(intended.Start_Object_ID) !== Number(existing.Start_Object_ID || 0) ||
|
|
372
|
+
Number(intended.End_Object_ID) !== Number(existing.End_Object_ID || 0);
|
|
373
|
+
if (changed && !o.dryRun) {
|
|
374
|
+
updateRow(db, 't_connector', ['Name', 'Connector_Type', 'Stereotype', 'Notes', 'Direction', 'Start_Object_ID', 'End_Object_ID'], 'Connector_ID', Number(existing.Connector_ID), intended);
|
|
375
|
+
}
|
|
376
|
+
stats[changed ? 'updated' : 'skipped'].relationships++;
|
|
377
|
+
} else {
|
|
378
|
+
newRels.push({ ...intended, ea_guid: guid, _alias: alias });
|
|
379
|
+
stats.added.relationships++;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const relAliasToId = new Map();
|
|
383
|
+
if (!o.dryRun) {
|
|
384
|
+
if (newRels.length > 0) {
|
|
385
|
+
insertMany(db, 't_connector', ['Name', 'Connector_Type', 'Stereotype', 'Notes', 'Direction', 'Start_Object_ID', 'End_Object_ID', 'ea_guid'], newRels);
|
|
386
|
+
}
|
|
387
|
+
for (let i = 0; i < newRels.length; i += 200) {
|
|
388
|
+
const part = newRels.slice(i, i + 200);
|
|
389
|
+
const marks = part.map(() => '?').join(',');
|
|
390
|
+
const rows = db.prepare('SELECT Connector_ID, ea_guid FROM t_connector WHERE ea_guid IN (' + marks + ')').all(...part.map((r) => r.ea_guid));
|
|
391
|
+
for (const r of rows) { const pr = part.find((x) => x.ea_guid === r.ea_guid); if (pr) { relAliasToId.set(pr._alias, Number(r.Connector_ID)); } }
|
|
392
|
+
}
|
|
393
|
+
} else {
|
|
394
|
+
for (const r of newRels) { relAliasToId.set(r._alias, -1); }
|
|
395
|
+
}
|
|
396
|
+
stages.relationships = nowMs();
|
|
397
|
+
|
|
398
|
+
// connector anchors + meta
|
|
399
|
+
if (!o.dryRun) {
|
|
400
|
+
for (const rel of graph.relationships || []) {
|
|
401
|
+
if (!rel || rel.id === undefined || rel.id === null) { continue; }
|
|
402
|
+
const id = relAliasToId.get(String(rel.id));
|
|
403
|
+
if (id === undefined || id < 0) { continue; }
|
|
404
|
+
upsertMeta(db, 'relationship', rel.id, rel);
|
|
405
|
+
const existingCt = db.prepare('SELECT PropertyID FROM t_connectortag WHERE ElementID=? AND Property=?').get(Number(id), 'schema_id');
|
|
406
|
+
if (!existingCt) {
|
|
407
|
+
db.prepare('INSERT INTO t_connectortag (ElementID, Property, VALUE, NOTES) VALUES (?,?,?,?)')
|
|
408
|
+
.run(Number(id), 'schema_id', rel.id, '');
|
|
409
|
+
db.prepare('INSERT INTO t_connectortag (ElementID, Property, VALUE, NOTES) VALUES (?,?,?,?)')
|
|
410
|
+
.run(Number(id), 'archimate_relationship_type', canonicalArchimateType(rel.type), '');
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
stages.relTags = nowMs();
|
|
415
|
+
|
|
416
|
+
// --- views/diagrams ----------------------------------------------------
|
|
417
|
+
const existingDiags = db.prepare('SELECT Diagram_ID, Package_ID, Name, StyleEx, ea_guid FROM t_diagram WHERE Package_ID=?').all(syncId);
|
|
418
|
+
const diagByView = new Map();
|
|
419
|
+
for (const d of existingDiags) {
|
|
420
|
+
const v = parseStyleToken(d.StyleEx, 'schema_view_id');
|
|
421
|
+
if (v) { diagByView.set(v, d); }
|
|
422
|
+
}
|
|
423
|
+
const newDiags = [];
|
|
424
|
+
for (const view of graph.views || []) {
|
|
425
|
+
if (!view || view.view_id === undefined || view.view_id === null) { continue; }
|
|
426
|
+
const viewId = String(view.view_id);
|
|
427
|
+
const styleEx = 'schema_view_id=' + viewId + ';';
|
|
428
|
+
const parentObjectId = (function () {
|
|
429
|
+
if (view.parent_element_id !== undefined && view.parent_element_id !== null && view.parent_element_id !== '') {
|
|
430
|
+
const pid = elemIdByAliasAll.get(String(view.parent_element_id));
|
|
431
|
+
if (pid !== undefined) { return pid; }
|
|
432
|
+
}
|
|
433
|
+
return 0;
|
|
434
|
+
})();
|
|
435
|
+
const existing = diagByView.get(viewId);
|
|
436
|
+
const intended = {
|
|
437
|
+
Name: safeName(view.view_name, viewId),
|
|
438
|
+
Diagram_Type: DIAGRAM_TYPE,
|
|
439
|
+
Package_ID: syncId,
|
|
440
|
+
ParentID: parentObjectId,
|
|
441
|
+
Notes: '', // EA .qea 不保留多段 Notes;视图内容经 kg_sync_meta 保真
|
|
442
|
+
StyleEx: styleEx,
|
|
443
|
+
};
|
|
444
|
+
if (existing) {
|
|
445
|
+
const changed = intended.Name !== (existing.Name || '');
|
|
446
|
+
if (DEBUG && changed) { console.error('DEBUG diagram chg', viewId, JSON.stringify({n:[intended.Name,(existing.Name||'')], notes:[intended.Notes,(existing.Notes||'')]})); }
|
|
447
|
+
if (changed && !o.dryRun) {
|
|
448
|
+
updateRow(db, 't_diagram', ['Name'], 'Diagram_ID', Number(existing.Diagram_ID), intended);
|
|
449
|
+
}
|
|
450
|
+
stats[changed ? 'updated' : 'skipped'].diagrams++;
|
|
451
|
+
} else {
|
|
452
|
+
newDiags.push({ ...intended, ea_guid: deterministicGuid('diag:' + viewId) });
|
|
453
|
+
stats.added.diagrams++;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const diagAliasToId = new Map();
|
|
457
|
+
if (!o.dryRun) {
|
|
458
|
+
if (newDiags.length > 0) {
|
|
459
|
+
insertMany(db, 't_diagram', ['Name', 'Diagram_Type', 'Package_ID', 'ParentID', 'StyleEx', 'ea_guid'], newDiags);
|
|
460
|
+
}
|
|
461
|
+
for (let i = 0; i < newDiags.length; i += 200) {
|
|
462
|
+
const part = newDiags.slice(i, i + 200);
|
|
463
|
+
const marks = part.map(() => '?').join(',');
|
|
464
|
+
const rows = db.prepare('SELECT Diagram_ID, StyleEx FROM t_diagram WHERE StyleEx IN (' + marks + ')').all(...part.map((d) => d.StyleEx));
|
|
465
|
+
for (const r of rows) {
|
|
466
|
+
const v = parseStyleToken(r.StyleEx, 'schema_view_id');
|
|
467
|
+
if (v) { diagAliasToId.set(v, Number(r.Diagram_ID)); }
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
for (const d of newDiags) { diagAliasToId.set(parseStyleToken(d.StyleEx, 'schema_view_id'), -1); }
|
|
472
|
+
}
|
|
473
|
+
// view meta
|
|
474
|
+
if (!o.dryRun) {
|
|
475
|
+
for (const view of graph.views || []) {
|
|
476
|
+
if (view && view.view_id !== undefined && view.view_id !== null) { upsertMeta(db, 'view', view.view_id, view); }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
// memberships: only INSERT missing; never touch existing geometry
|
|
480
|
+
for (const view of graph.views || []) {
|
|
481
|
+
if (!view || view.view_id === undefined || view.view_id === null) { continue; }
|
|
482
|
+
const viewId = String(view.view_id);
|
|
483
|
+
let dId = diagByView.get(viewId);
|
|
484
|
+
if (!dId) { dId = diagAliasToId.get(viewId); }
|
|
485
|
+
if (!dId) { continue; }
|
|
486
|
+
const diagramId = Number(dId.Diagram_ID !== undefined ? dId.Diagram_ID : dId);
|
|
487
|
+
const placedObjs = new Set();
|
|
488
|
+
const objs = db.prepare('SELECT Object_ID FROM t_diagramobjects WHERE Diagram_ID=?').all(diagramId);
|
|
489
|
+
for (const r of objs) { placedObjs.add(Number(r.Object_ID)); }
|
|
490
|
+
const nextSeq = objs.length;
|
|
491
|
+
const newObjs = [];
|
|
492
|
+
const incl = view.included_elements || [];
|
|
493
|
+
let seq = nextSeq;
|
|
494
|
+
for (const elId of incl) {
|
|
495
|
+
const oid = elemIdByAliasAll.get(String(elId));
|
|
496
|
+
if (oid === undefined) { continue; }
|
|
497
|
+
if (placedObjs.has(Number(oid))) { continue; }
|
|
498
|
+
const col = seq % 6;
|
|
499
|
+
const row = Math.floor(seq / 6);
|
|
500
|
+
newObjs.push({
|
|
501
|
+
Diagram_ID: diagramId, Object_ID: Number(oid),
|
|
502
|
+
RectLeft: 40 + col * 260, RectTop: 40 + row * 160,
|
|
503
|
+
RectRight: 40 + col * 260 + 180, RectBottom: 40 + row * 160 + 90,
|
|
504
|
+
Sequence: seq,
|
|
505
|
+
});
|
|
506
|
+
seq++;
|
|
507
|
+
}
|
|
508
|
+
if (newObjs.length > 0 && !o.dryRun) {
|
|
509
|
+
insertMany(db, 't_diagramobjects', ['Diagram_ID', 'Object_ID', 'RectLeft', 'RectTop', 'RectRight', 'RectBottom', 'Sequence'], newObjs);
|
|
510
|
+
}
|
|
511
|
+
stats.added.diagramObjects += newObjs.length;
|
|
512
|
+
|
|
513
|
+
const placedLinks = new Set();
|
|
514
|
+
const links = db.prepare('SELECT ConnectorID FROM t_diagramlinks WHERE DiagramID=?').all(diagramId);
|
|
515
|
+
for (const r of links) { placedLinks.add(Number(r.ConnectorID)); }
|
|
516
|
+
const newLinks = [];
|
|
517
|
+
for (const relId of view.included_relationships || []) {
|
|
518
|
+
const cid = relAliasToId.get(String(relId));
|
|
519
|
+
if (cid === undefined || cid < 0) { continue; }
|
|
520
|
+
if (placedLinks.has(Number(cid))) { continue; }
|
|
521
|
+
newLinks.push({ DiagramID: diagramId, ConnectorID: Number(cid), Style: '', Geometry: '' });
|
|
522
|
+
}
|
|
523
|
+
if (newLinks.length > 0 && !o.dryRun) {
|
|
524
|
+
insertMany(db, 't_diagramlinks', ['DiagramID', 'ConnectorID', 'Style', 'Geometry'], newLinks);
|
|
525
|
+
}
|
|
526
|
+
stats.added.diagramLinks += newLinks.length;
|
|
527
|
+
}
|
|
528
|
+
stages.members = nowMs();
|
|
529
|
+
|
|
530
|
+
// --- deletion reconcile (opt-in) ---------------------------------------
|
|
531
|
+
const keepAliases = new Set();
|
|
532
|
+
for (const e of graph.elements || []) { if (e && e.id !== undefined) { keepAliases.add(String(e.id)); } }
|
|
533
|
+
for (const rel of graph.relationships || []) { if (rel && rel.id !== undefined) { keepAliases.add(String(rel.id)); } }
|
|
534
|
+
const candidates = [];
|
|
535
|
+
for (const row of existingElems) {
|
|
536
|
+
if (row.Alias && !keepAliases.has(String(row.Alias))) { candidates.push({ type: 'element', id: Number(row.Object_ID), alias: row.Alias }); }
|
|
537
|
+
}
|
|
538
|
+
for (const row of existingRels) {
|
|
539
|
+
if (row.Alias && !keepAliases.has(String(row.Alias))) { candidates.push({ type: 'relationship', id: Number(row.Connector_ID), alias: row.Alias }); }
|
|
540
|
+
}
|
|
541
|
+
stats.deleteCandidates = candidates.length;
|
|
542
|
+
if (candidates.length > 0 && o.allowDelete && !o.dryRun) {
|
|
543
|
+
for (const c of candidates) {
|
|
544
|
+
if (c.type === 'relationship') {
|
|
545
|
+
db.prepare('DELETE FROM t_connector WHERE Connector_ID=?').run(c.id);
|
|
546
|
+
} else {
|
|
547
|
+
db.prepare('DELETE FROM t_diagramobjects WHERE Object_ID=?').run(c.id);
|
|
548
|
+
db.prepare('DELETE FROM t_objectproperties WHERE Object_ID=?').run(c.id);
|
|
549
|
+
db.prepare('DELETE FROM t_object WHERE Object_ID=?').run(c.id);
|
|
550
|
+
}
|
|
551
|
+
stats.deleted++;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
stages.deletes = nowMs();
|
|
555
|
+
if (!o.dryRun) { db.exec('COMMIT'); }
|
|
556
|
+
} finally {
|
|
557
|
+
try { db.close(); } catch { /* ignore */ }
|
|
558
|
+
}
|
|
559
|
+
stats.ms = {
|
|
560
|
+
total: nowMs() - t0,
|
|
561
|
+
package: stages.package - t0,
|
|
562
|
+
elements: stages.elements - stages.package,
|
|
563
|
+
elemTags: stages.elemTags - stages.elements,
|
|
564
|
+
relationships: stages.relationships - stages.elemTags,
|
|
565
|
+
relTags: stages.relTags - stages.relationships,
|
|
566
|
+
views: stages.members - stages.relTags,
|
|
567
|
+
members: stages.members - stages.relTags,
|
|
568
|
+
};
|
|
569
|
+
return { ok: true, syncPackageId: stats.syncPackageId, stats };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Full projection (whole-file rebuild, decision qea-full-wholefile-argo-scripts-no-config):
|
|
575
|
+
// wipe ALL existing content in the target .qea (every user table row, incl. kg_sync_meta),
|
|
576
|
+
// re-seed the minimal root package, then rebuild the whole .qea purely from the canonical
|
|
577
|
+
// graph. EA is treated as a projection: after full there is nothing but canonical content.
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
function wipeAllContent(db) {
|
|
580
|
+
const rows = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
581
|
+
const cleared = {};
|
|
582
|
+
for (const r of rows) {
|
|
583
|
+
try {
|
|
584
|
+
const before = db.prepare('SELECT COUNT(*) AS c FROM "' + r.name + '"').get().c;
|
|
585
|
+
db.exec('DELETE FROM "' + r.name + '"');
|
|
586
|
+
cleared[r.name] = before;
|
|
587
|
+
} catch { /* skip locked/system tables */ }
|
|
588
|
+
}
|
|
589
|
+
try { db.exec('DELETE FROM sqlite_sequence'); } catch { /* ignore */ }
|
|
590
|
+
return cleared;
|
|
591
|
+
}
|
|
592
|
+
function ensureRootPackage(db) {
|
|
593
|
+
const roots = db.prepare('SELECT Package_ID FROM t_package WHERE Parent_ID=0 ORDER BY Package_ID').all();
|
|
594
|
+
if (roots.length > 0) { return Number(roots[0].Package_ID); }
|
|
595
|
+
const r = db.prepare('INSERT INTO t_package (Name, Parent_ID, ea_guid) VALUES (?,?,?)').run('Model', 0, deterministicGuid('pkg:Model'));
|
|
596
|
+
return Number(r.lastInsertRowid);
|
|
597
|
+
}
|
|
598
|
+
function verifyQeaCanonical(graph, qeaPath) {
|
|
599
|
+
let roundtrip = null;
|
|
600
|
+
try { roundtrip = require('../../tests/_ea-roundtrip-lib.js'); } catch { roundtrip = null; }
|
|
601
|
+
const exp = exportQeaToGraph(qeaPath);
|
|
602
|
+
const counts = {
|
|
603
|
+
elements: exp.elements.length, relationships: exp.relationships.length, views: exp.views.length,
|
|
604
|
+
sourceElements: (graph.elements || []).length,
|
|
605
|
+
sourceRelationships: (graph.relationships || []).length,
|
|
606
|
+
sourceViews: (graph.views || []).length,
|
|
607
|
+
};
|
|
608
|
+
let equal = counts.elements === counts.sourceElements && counts.relationships === counts.sourceRelationships && counts.views === counts.sourceViews;
|
|
609
|
+
let diffs = null;
|
|
610
|
+
if (roundtrip && typeof roundtrip.compareRoundtrip === 'function') {
|
|
611
|
+
const rep = roundtrip.compareRoundtrip(graph, exp, {});
|
|
612
|
+
equal = rep.equal;
|
|
613
|
+
diffs = { missing: rep.missingInExport.length, extra: rep.extraInExport.length, valueDiffs: rep.valueDiffs.length };
|
|
614
|
+
}
|
|
615
|
+
return { consistent: equal, diffs, counts };
|
|
616
|
+
}
|
|
617
|
+
function fullProjection(graph, qeaPath, opts) {
|
|
618
|
+
const o = opts || {};
|
|
619
|
+
const t0 = nowMs();
|
|
620
|
+
let wiped = null;
|
|
621
|
+
let db = null;
|
|
622
|
+
try {
|
|
623
|
+
db = openQea(qeaPath);
|
|
624
|
+
ensureMetaTable(db);
|
|
625
|
+
db.exec('BEGIN IMMEDIATE');
|
|
626
|
+
wiped = wipeAllContent(db);
|
|
627
|
+
ensureRootPackage(db);
|
|
628
|
+
db.exec('COMMIT');
|
|
629
|
+
} finally {
|
|
630
|
+
if (db) { try { db.close(); } catch { /* ignore */ } }
|
|
631
|
+
}
|
|
632
|
+
const sync = syncGraphToQea(graph, qeaPath, { dryRun: o.dryRun, allowDelete: false });
|
|
633
|
+
const verification = o.verify === false ? null : verifyQeaCanonical(graph, qeaPath);
|
|
634
|
+
return { ok: true, wiped, sync: sync.stats, verification, ms: { total: nowMs() - t0 } };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
// Export
|
|
639
|
+
// ---------------------------------------------------------------------------
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// Export
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
function exportQeaToGraph(qeaPath) {
|
|
644
|
+
const db = openQea(qeaPath);
|
|
645
|
+
try {
|
|
646
|
+
ensureMetaTable(db);
|
|
647
|
+
const elements = readMetaKind(db, 'element').filter((e) => e && e.id !== undefined);
|
|
648
|
+
const relationships = readMetaKind(db, 'relationship').filter((r) => r && r.id !== undefined);
|
|
649
|
+
const views = readMetaKind(db, 'view').filter((v) => v && v.view_id !== undefined);
|
|
650
|
+
return { name: 'ArchGraph (from archgraph.qea)', description: '', elements, relationships, views };
|
|
651
|
+
} finally {
|
|
652
|
+
db.close();
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// snapshot
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
function snapshotQea(qeaPath, snapshotDir) {
|
|
660
|
+
const fs = require('node:fs');
|
|
661
|
+
const path = require('node:path');
|
|
662
|
+
if (!fs.existsSync(qeaPath)) { return null; }
|
|
663
|
+
const dir = snapshotDir || path.dirname(qeaPath);
|
|
664
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
665
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
666
|
+
const target = path.join(dir, path.basename(qeaPath).replace(/(\.qea)$/i, '_before_sync_' + ts + '$1'));
|
|
667
|
+
fs.copyFileSync(qeaPath, target);
|
|
668
|
+
return target;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
module.exports = {
|
|
672
|
+
SYNC_PACKAGE_NAME,
|
|
673
|
+
META_TABLE,
|
|
674
|
+
openQea,
|
|
675
|
+
deterministicGuid,
|
|
676
|
+
elementObjectType,
|
|
677
|
+
elementStereotype,
|
|
678
|
+
relationshipMap,
|
|
679
|
+
canonicalArchimateType,
|
|
680
|
+
syncGraphToQea,
|
|
681
|
+
exportQeaToGraph,
|
|
682
|
+
snapshotQea,
|
|
683
|
+
readMetaKind,
|
|
684
|
+
fullProjection,
|
|
685
|
+
wipeAllContent,
|
|
686
|
+
ensureRootPackage,
|
|
687
|
+
verifyQeaCanonical,
|
|
688
|
+
};
|