@willwade/aac-processors 0.2.13 → 0.2.15
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/dist/browser/core/treeStructure.js +15 -0
- package/dist/browser/processors/applePanelsProcessor.js +2 -1
- package/dist/browser/processors/astericsGridProcessor.js +2 -1
- package/dist/browser/processors/dotProcessor.js +4 -2
- package/dist/browser/processors/gridsetProcessor.js +2 -2
- package/dist/browser/processors/opmlProcessor.js +2 -1
- package/dist/browser/processors/snapProcessor.js +321 -2
- package/dist/browser/processors/touchchatProcessor.js +3 -3
- package/dist/core/treeStructure.d.ts +11 -0
- package/dist/core/treeStructure.js +15 -0
- package/dist/processors/applePanelsProcessor.js +2 -1
- package/dist/processors/astericsGridProcessor.js +2 -1
- package/dist/processors/dotProcessor.js +4 -2
- package/dist/processors/gridsetProcessor.js +2 -2
- package/dist/processors/opmlProcessor.js +2 -1
- package/dist/processors/snapProcessor.d.ts +24 -0
- package/dist/processors/snapProcessor.js +321 -2
- package/dist/processors/touchchatProcessor.js +3 -3
- package/package.json +1 -1
|
@@ -201,6 +201,21 @@ export class AACPage {
|
|
|
201
201
|
// Record the mutation
|
|
202
202
|
this._pendingMutations.push({ type: 'addButton', button });
|
|
203
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* Internal load-path button push: adds a button to the page WITHOUT recording a mutation.
|
|
206
|
+
* Used by processors during loadIntoTree so the loaded baseline isn't treated as user changes.
|
|
207
|
+
* Not part of the public API — consumers should always use addButton.
|
|
208
|
+
*/
|
|
209
|
+
_loadButton(button) {
|
|
210
|
+
this.buttons.push(button);
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Discard all recorded mutations on this page.
|
|
214
|
+
* Useful as an escape hatch after loadIntoTree if the consumer wants a clean baseline.
|
|
215
|
+
*/
|
|
216
|
+
clearMutations() {
|
|
217
|
+
this._pendingMutations = [];
|
|
218
|
+
}
|
|
204
219
|
/**
|
|
205
220
|
* Get the list of pending mutations for this page (read-only)
|
|
206
221
|
*/
|
|
@@ -219,7 +219,8 @@ class ApplePanelsProcessor extends BaseProcessor {
|
|
|
219
219
|
fontWeight: btn.DisplayImageWeight === 'bold' ? 'bold' : 'normal',
|
|
220
220
|
},
|
|
221
221
|
});
|
|
222
|
-
|
|
222
|
+
// Load path: do not record as a user mutation
|
|
223
|
+
page._loadButton(button);
|
|
223
224
|
if (btn.Rect) {
|
|
224
225
|
const rect = this.parseRect(btn.Rect);
|
|
225
226
|
if (rect) {
|
|
@@ -714,7 +714,8 @@ class AstericsGridProcessor extends BaseProcessor {
|
|
|
714
714
|
}
|
|
715
715
|
grid.gridElements.forEach((element) => {
|
|
716
716
|
const button = this.createButtonFromElement(element, colorConfig, activeColorSchemeDefinition);
|
|
717
|
-
|
|
717
|
+
// Load path: do not record as a user mutation
|
|
718
|
+
page._loadButton(button);
|
|
718
719
|
const buttonX = element.x || 0;
|
|
719
720
|
const buttonY = element.y || 0;
|
|
720
721
|
const buttonWidth = element.width || 1;
|
|
@@ -117,7 +117,8 @@ class DotProcessor extends BaseProcessor {
|
|
|
117
117
|
});
|
|
118
118
|
tree.addPage(page);
|
|
119
119
|
// Add a self button so single-node graphs yield one button
|
|
120
|
-
|
|
120
|
+
// Load path: do not record as a user mutation
|
|
121
|
+
page._loadButton(new AACButton({
|
|
121
122
|
id: `${node.id}_self`,
|
|
122
123
|
label: node.label,
|
|
123
124
|
message: node.label,
|
|
@@ -138,7 +139,8 @@ class DotProcessor extends BaseProcessor {
|
|
|
138
139
|
message: '',
|
|
139
140
|
targetPageId: edge.to,
|
|
140
141
|
});
|
|
141
|
-
|
|
142
|
+
// Load path: do not record as a user mutation
|
|
143
|
+
fromPage._loadButton(button);
|
|
142
144
|
}
|
|
143
145
|
}
|
|
144
146
|
return tree;
|
|
@@ -1571,8 +1571,8 @@ class GridsetProcessor extends BaseProcessor {
|
|
|
1571
1571
|
...(imageData ? { imageData, image_id: resolvedImageEntry } : {}),
|
|
1572
1572
|
},
|
|
1573
1573
|
});
|
|
1574
|
-
// Add button to page
|
|
1575
|
-
page.
|
|
1574
|
+
// Add button to page (load path: do not record as a user mutation)
|
|
1575
|
+
page._loadButton(button);
|
|
1576
1576
|
// Place button in grid layout (handle colspan/rowspan)
|
|
1577
1577
|
for (let r = cellY; r < cellY + rowSpan && r < maxRows; r++) {
|
|
1578
1578
|
for (let c = cellX; c < cellX + colSpan && c < maxCols; c++) {
|
|
@@ -42,7 +42,8 @@ class OpmlProcessor extends BaseProcessor {
|
|
|
42
42
|
message: '',
|
|
43
43
|
targetPageId: childText.replace(/[^a-zA-Z0-9]/g, '_'),
|
|
44
44
|
});
|
|
45
|
-
|
|
45
|
+
// Load path: do not record as a user mutation
|
|
46
|
+
page._loadButton(button);
|
|
46
47
|
const { page: childPage, childPages: grandChildren } = this.processOutline(child, page.id);
|
|
47
48
|
if (childPage && childPage.id)
|
|
48
49
|
childPages.push(childPage, ...grandChildren);
|
|
@@ -39,7 +39,7 @@ class SnapProcessor extends BaseProcessor {
|
|
|
39
39
|
super(options);
|
|
40
40
|
this.capabilities = {
|
|
41
41
|
wordList: 'none',
|
|
42
|
-
preservesAssetsOnSave:
|
|
42
|
+
preservesAssetsOnSave: true,
|
|
43
43
|
newCellCreation: 'allowed',
|
|
44
44
|
};
|
|
45
45
|
this.symbolResolver = null;
|
|
@@ -579,7 +579,8 @@ class SnapProcessor extends BaseProcessor {
|
|
|
579
579
|
// Add to the intended parent page
|
|
580
580
|
const parentPage = tree.getPage(parentUniqueId);
|
|
581
581
|
if (parentPage) {
|
|
582
|
-
|
|
582
|
+
// Load path: do not record as a user mutation
|
|
583
|
+
parentPage._loadButton(button);
|
|
583
584
|
// Add button to grid layout if position data is available
|
|
584
585
|
const gridPositionStr = String(btnRow.GridPosition || '');
|
|
585
586
|
if (gridPositionStr && gridPositionStr.includes(',')) {
|
|
@@ -809,6 +810,324 @@ class SnapProcessor extends BaseProcessor {
|
|
|
809
810
|
await this.saveFromTree(tree, outputPath);
|
|
810
811
|
return await readBinaryFromInput(outputPath);
|
|
811
812
|
}
|
|
813
|
+
/**
|
|
814
|
+
* Save a modified tree while preserving the original SQLite schema and data.
|
|
815
|
+
*
|
|
816
|
+
* Strategy: copy the original .sps verbatim, then open the copy and replay
|
|
817
|
+
* `page.pendingMutations` as targeted SQL UPDATE/INSERT statements. Everything
|
|
818
|
+
* not in the mutation log (PageLayout, ScanGroup, image blobs, ContentTypeData,
|
|
819
|
+
* ButtonPageLink, etc.) is preserved byte-for-byte from the original.
|
|
820
|
+
*
|
|
821
|
+
* This is the asset-preserving counterpart to `saveFromTree` (which builds a
|
|
822
|
+
* stripped-down DB from scratch and is unsuitable for round-tripping real
|
|
823
|
+
* TD Snap page sets).
|
|
824
|
+
*
|
|
825
|
+
* Supported mutations:
|
|
826
|
+
* - updateButton(id, patch) → UPDATE Button SET Label/Message WHERE Id = ?
|
|
827
|
+
* - removeButton(id) → UPDATE ElementPlacement SET Visible = 0 for all
|
|
828
|
+
* placements pointing at the button's ElementReference
|
|
829
|
+
* - addButton(button) → INSERT into ElementReference + Button + one
|
|
830
|
+
* ElementPlacement per existing PageLayout for
|
|
831
|
+
* the target page (so the button shows in every
|
|
832
|
+
* layout the user has). Image/audio not yet handled.
|
|
833
|
+
*
|
|
834
|
+
* WordList mutations are no-ops on Snap (capabilities.wordList === 'none').
|
|
835
|
+
*/
|
|
836
|
+
async saveModifiedTree(originalPath, tree, outputPath) {
|
|
837
|
+
const { pathExists, mkDir, removePath, dirname, readBinaryFromInput, writeBinaryToPath } = this.options.fileAdapter;
|
|
838
|
+
if (!isNodeRuntime()) {
|
|
839
|
+
throw new Error('saveModifiedTree is only supported in Node.js for Snap files.');
|
|
840
|
+
}
|
|
841
|
+
const outputDir = dirname(outputPath);
|
|
842
|
+
if (!(await pathExists(outputDir))) {
|
|
843
|
+
await mkDir(outputDir, { recursive: true });
|
|
844
|
+
}
|
|
845
|
+
if (await pathExists(outputPath)) {
|
|
846
|
+
await removePath(outputPath);
|
|
847
|
+
}
|
|
848
|
+
// 1. Copy the original verbatim — preserves all 23+ tables, blobs, settings.
|
|
849
|
+
const originalBytes = await readBinaryFromInput(originalPath);
|
|
850
|
+
await writeBinaryToPath(outputPath, originalBytes);
|
|
851
|
+
// Short-circuit: if no page has any mutations, we're done.
|
|
852
|
+
const hasAnyMutations = Object.values(tree.pages).some((page) => page.pendingMutations.length > 0);
|
|
853
|
+
if (!hasAnyMutations) {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
// 2. Open the copy.
|
|
857
|
+
const Database = requireBetterSqlite3();
|
|
858
|
+
const db = new Database(outputPath, { readonly: false });
|
|
859
|
+
try {
|
|
860
|
+
// 3. Schema introspection — different Snap versions have different optional columns.
|
|
861
|
+
const tableColumns = (table) => {
|
|
862
|
+
try {
|
|
863
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
864
|
+
return new Set(rows.map((r) => r.name));
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
return new Set();
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
const buttonCols = tableColumns('Button');
|
|
871
|
+
const placementCols = tableColumns('ElementPlacement');
|
|
872
|
+
const hasPlacementVisible = placementCols.has('Visible');
|
|
873
|
+
const hasPlacementPageLayoutId = placementCols.has('PageLayoutId');
|
|
874
|
+
// 4. UniqueId → numeric Page.Id map.
|
|
875
|
+
const pageRows = db.prepare('SELECT Id, UniqueId FROM Page').all();
|
|
876
|
+
const uniqueIdToPageId = new Map();
|
|
877
|
+
for (const row of pageRows) {
|
|
878
|
+
if (row.UniqueId)
|
|
879
|
+
uniqueIdToPageId.set(String(row.UniqueId), row.Id);
|
|
880
|
+
// Allow lookup by stringified numeric Id too, since loadIntoTree falls back to it.
|
|
881
|
+
uniqueIdToPageId.set(String(row.Id), row.Id);
|
|
882
|
+
}
|
|
883
|
+
const layoutsByPage = new Map();
|
|
884
|
+
try {
|
|
885
|
+
const layoutRows = db
|
|
886
|
+
.prepare('SELECT Id, PageId, PageLayoutSetting FROM PageLayout')
|
|
887
|
+
.all();
|
|
888
|
+
// Pre-load occupied placements per layout so we can avoid (x,y) collisions.
|
|
889
|
+
const placementsByLayout = new Map();
|
|
890
|
+
const placementRows = db
|
|
891
|
+
.prepare('SELECT PageLayoutId, GridPosition FROM ElementPlacement WHERE GridPosition IS NOT NULL AND PageLayoutId IS NOT NULL')
|
|
892
|
+
.all();
|
|
893
|
+
for (const r of placementRows) {
|
|
894
|
+
let set = placementsByLayout.get(r.PageLayoutId);
|
|
895
|
+
if (!set) {
|
|
896
|
+
set = new Set();
|
|
897
|
+
placementsByLayout.set(r.PageLayoutId, set);
|
|
898
|
+
}
|
|
899
|
+
set.add(r.GridPosition);
|
|
900
|
+
}
|
|
901
|
+
for (const row of layoutRows) {
|
|
902
|
+
const parts = String(row.PageLayoutSetting ?? '').split(',');
|
|
903
|
+
const cols = parseInt(parts[0], 10) || 4;
|
|
904
|
+
const rows = parseInt(parts[1], 10) || 4;
|
|
905
|
+
const info = {
|
|
906
|
+
id: row.Id,
|
|
907
|
+
cols,
|
|
908
|
+
rows,
|
|
909
|
+
occupied: placementsByLayout.get(row.Id) ?? new Set(),
|
|
910
|
+
};
|
|
911
|
+
const list = layoutsByPage.get(row.PageId);
|
|
912
|
+
if (list)
|
|
913
|
+
list.push(info);
|
|
914
|
+
else
|
|
915
|
+
layoutsByPage.set(row.PageId, [info]);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
catch {
|
|
919
|
+
// PageLayout table may not exist on older schemas — placements get NULL PageLayoutId.
|
|
920
|
+
}
|
|
921
|
+
// Find first empty cell on a layout, starting from a preferred (x,y).
|
|
922
|
+
// Returns null if the layout is fully occupied.
|
|
923
|
+
const findFreeCell = (info, prefX, prefY) => {
|
|
924
|
+
const inBounds = (x, y) => x >= 0 && x < info.cols && y >= 0 && y < info.rows;
|
|
925
|
+
if (inBounds(prefX, prefY)) {
|
|
926
|
+
const key = `${prefX},${prefY}`;
|
|
927
|
+
if (!info.occupied.has(key)) {
|
|
928
|
+
info.occupied.add(key);
|
|
929
|
+
return key;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
for (let y = 0; y < info.rows; y++) {
|
|
933
|
+
for (let x = 0; x < info.cols; x++) {
|
|
934
|
+
const key = `${x},${y}`;
|
|
935
|
+
if (!info.occupied.has(key)) {
|
|
936
|
+
info.occupied.add(key);
|
|
937
|
+
return key;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
return null;
|
|
942
|
+
};
|
|
943
|
+
// Generate a UUID for new Button.UniqueId. Required: Node has crypto.randomUUID since 14.17.
|
|
944
|
+
const nodeCrypto = getNodeRequire()?.('crypto');
|
|
945
|
+
const uuid = () => nodeCrypto?.randomUUID
|
|
946
|
+
? nodeCrypto.randomUUID()
|
|
947
|
+
: // Fallback (very unlikely path, but shouldn't break older Nodes)
|
|
948
|
+
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
949
|
+
const r = (Math.random() * 16) | 0;
|
|
950
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
951
|
+
return v.toString(16);
|
|
952
|
+
});
|
|
953
|
+
// 5. Next-available IDs for inserts.
|
|
954
|
+
const nextId = (table) => {
|
|
955
|
+
const row = db.prepare(`SELECT COALESCE(MAX(Id), 0) AS maxId FROM ${table}`).get();
|
|
956
|
+
return (row.maxId || 0) + 1;
|
|
957
|
+
};
|
|
958
|
+
let nextButtonId = nextId('Button');
|
|
959
|
+
let nextElementRefId = nextId('ElementReference');
|
|
960
|
+
let nextPlacementId = nextId('ElementPlacement');
|
|
961
|
+
// CommandSequence is optional but TD Snap crashes on some pages (e.g. dashboards
|
|
962
|
+
// like "Google Home Speaker") when a button has no CommandSequence row. Every
|
|
963
|
+
// button in the original DB has one. We track this only if the table exists.
|
|
964
|
+
const hasCommandSequence = tableColumns('CommandSequence').size > 0;
|
|
965
|
+
let nextCommandSequenceId = hasCommandSequence ? nextId('CommandSequence') : 0;
|
|
966
|
+
// 6. Replay mutations inside one transaction for atomicity + speed.
|
|
967
|
+
const replay = db.transaction(() => {
|
|
968
|
+
for (const page of Object.values(tree.pages)) {
|
|
969
|
+
if (page.pendingMutations.length === 0)
|
|
970
|
+
continue;
|
|
971
|
+
const numericPageId = uniqueIdToPageId.get(String(page.id));
|
|
972
|
+
if (numericPageId === undefined) {
|
|
973
|
+
// eslint-disable-next-line no-console
|
|
974
|
+
console.warn(`[Snap] saveModifiedTree: page "${page.name}" (${page.id}) not found in original DB; skipping ${page.pendingMutations.length} mutation(s)`);
|
|
975
|
+
continue;
|
|
976
|
+
}
|
|
977
|
+
for (const mutation of page.pendingMutations) {
|
|
978
|
+
switch (mutation.type) {
|
|
979
|
+
case 'updateButton': {
|
|
980
|
+
const sets = [];
|
|
981
|
+
const args = [];
|
|
982
|
+
if (mutation.patch.label !== undefined && buttonCols.has('Label')) {
|
|
983
|
+
sets.push('Label = ?');
|
|
984
|
+
args.push(mutation.patch.label);
|
|
985
|
+
}
|
|
986
|
+
if (mutation.patch.message !== undefined && buttonCols.has('Message')) {
|
|
987
|
+
sets.push('Message = ?');
|
|
988
|
+
args.push(mutation.patch.message);
|
|
989
|
+
}
|
|
990
|
+
if (sets.length === 0)
|
|
991
|
+
break;
|
|
992
|
+
args.push(Number(mutation.buttonId));
|
|
993
|
+
db.prepare(`UPDATE Button SET ${sets.join(', ')} WHERE Id = ?`).run(...args);
|
|
994
|
+
break;
|
|
995
|
+
}
|
|
996
|
+
case 'removeButton': {
|
|
997
|
+
// Hide all placements that reference the button's ElementReference.
|
|
998
|
+
const buttonRow = db
|
|
999
|
+
.prepare('SELECT ElementReferenceId FROM Button WHERE Id = ?')
|
|
1000
|
+
.get(Number(mutation.buttonId));
|
|
1001
|
+
if (!buttonRow || buttonRow.ElementReferenceId == null)
|
|
1002
|
+
break;
|
|
1003
|
+
if (hasPlacementVisible) {
|
|
1004
|
+
db.prepare('UPDATE ElementPlacement SET Visible = 0 WHERE ElementReferenceId = ?').run(buttonRow.ElementReferenceId);
|
|
1005
|
+
}
|
|
1006
|
+
else {
|
|
1007
|
+
// Older schemas without Visible: delete the placements outright.
|
|
1008
|
+
db.prepare('DELETE FROM ElementPlacement WHERE ElementReferenceId = ?').run(buttonRow.ElementReferenceId);
|
|
1009
|
+
}
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
case 'addButton': {
|
|
1013
|
+
const button = mutation.button;
|
|
1014
|
+
const elementRefId = nextElementRefId++;
|
|
1015
|
+
const buttonId = nextButtonId++;
|
|
1016
|
+
// ElementReference: TD Snap requires ElementType, ForegroundColor,
|
|
1017
|
+
// BackgroundColor, and AudioCueRecordingId to be non-NULL on render
|
|
1018
|
+
// — NULL values crash dashboard pages (e.g. "Google Home Speaker").
|
|
1019
|
+
// Defaults below match the modal values across real Snap files
|
|
1020
|
+
// (>99% of existing rows in Core First Scanning use them).
|
|
1021
|
+
const erColumns = tableColumns('ElementReference');
|
|
1022
|
+
const erCandidates = [
|
|
1023
|
+
{ col: 'Id', value: elementRefId },
|
|
1024
|
+
{ col: 'PageId', value: numericPageId },
|
|
1025
|
+
{ col: 'ElementType', value: 0 }, // 0 = button; only nonzero in 1/20608 rows
|
|
1026
|
+
{ col: 'ForegroundColor', value: -14934754 }, // dark text default (99.8% of rows)
|
|
1027
|
+
{ col: 'BackgroundColor', value: -132102 }, // light cell default (85.7% of rows)
|
|
1028
|
+
{ col: 'AudioCueRecordingId', value: 0 }, // 0 = no audio cue (99.99% of rows)
|
|
1029
|
+
];
|
|
1030
|
+
const erFieldsPresent = erCandidates.filter((f) => erColumns.has(f.col));
|
|
1031
|
+
db.prepare(`INSERT INTO ElementReference (${erFieldsPresent
|
|
1032
|
+
.map((f) => f.col)
|
|
1033
|
+
.join(', ')}) VALUES (${erFieldsPresent.map(() => '?').join(', ')})`).run(...erFieldsPresent.map((f) => f.value));
|
|
1034
|
+
// Button: provide non-NULL defaults for columns TD Snap reads.
|
|
1035
|
+
// ContentType = 6 (normal speak button), CommandFlags = 8 (standard),
|
|
1036
|
+
// LabelOwnership / ImageOwnership = 3 (owned by this page set),
|
|
1037
|
+
// ActiveContentType = 0, BorderThickness = 0, UniqueId = fresh GUID,
|
|
1038
|
+
// image / sound / symbol IDs = 0 (= "no asset").
|
|
1039
|
+
const candidateFields = [
|
|
1040
|
+
{ col: 'Id', value: buttonId },
|
|
1041
|
+
{ col: 'Label', value: button.label || '' },
|
|
1042
|
+
{ col: 'Message', value: button.message || button.label || '' },
|
|
1043
|
+
{ col: 'ElementReferenceId', value: elementRefId },
|
|
1044
|
+
{ col: 'ContentType', value: 6 },
|
|
1045
|
+
{ col: 'CommandFlags', value: 8 },
|
|
1046
|
+
{ col: 'LabelOwnership', value: 3 },
|
|
1047
|
+
{ col: 'ImageOwnership', value: 3 },
|
|
1048
|
+
{ col: 'ActiveContentType', value: 0 },
|
|
1049
|
+
{ col: 'BorderThickness', value: 0 },
|
|
1050
|
+
{ col: 'UniqueId', value: uuid() },
|
|
1051
|
+
{ col: 'LibrarySymbolId', value: 0 },
|
|
1052
|
+
{ col: 'PageSetImageId', value: 0 },
|
|
1053
|
+
{ col: 'MessageRecordingId', value: 0 },
|
|
1054
|
+
// UseMessageRecording: omit. 99.99% of existing rows have NULL,
|
|
1055
|
+
// and forcing 0 makes Sarah/Mum the only outliers in the DB.
|
|
1056
|
+
{ col: 'SymbolColorDataId', value: 0 },
|
|
1057
|
+
];
|
|
1058
|
+
const presentFields = candidateFields.filter((f) => buttonCols.has(f.col));
|
|
1059
|
+
const sql = `INSERT INTO Button (${presentFields
|
|
1060
|
+
.map((f) => f.col)
|
|
1061
|
+
.join(', ')}) VALUES (${presentFields.map(() => '?').join(', ')})`;
|
|
1062
|
+
db.prepare(sql).run(...presentFields.map((f) => f.value));
|
|
1063
|
+
// Insert a default CommandSequence row that explicitly speaks the
|
|
1064
|
+
// button's Label ($type:3 = MessageAction, value 0 = speak Label).
|
|
1065
|
+
// This is the most common pattern in real Snap files (9514 / 19402
|
|
1066
|
+
// buttons in Core First Scanning). Without an explicit action,
|
|
1067
|
+
// dashboard pages (e.g. "Google Home Speaker") crash on render —
|
|
1068
|
+
// empty $values is only used for hidden/help-text buttons.
|
|
1069
|
+
if (hasCommandSequence) {
|
|
1070
|
+
db.prepare('INSERT INTO CommandSequence (Id, SerializedCommands, ButtonId) VALUES (?, ?, ?)').run(nextCommandSequenceId++, '{"$type":"1","$values":[{"$type":"3","MessageAction":0}]}', buttonId);
|
|
1071
|
+
}
|
|
1072
|
+
const layouts = layoutsByPage.get(numericPageId) ?? [];
|
|
1073
|
+
const prefX = button.x ?? 0;
|
|
1074
|
+
const prefY = button.y ?? 0;
|
|
1075
|
+
if (layouts.length === 0) {
|
|
1076
|
+
const placementId = nextPlacementId++;
|
|
1077
|
+
if (hasPlacementPageLayoutId) {
|
|
1078
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId, Visible) VALUES (?, ?, ?, '1,1', NULL, 1)").run(placementId, elementRefId, `${prefX},${prefY}`);
|
|
1079
|
+
}
|
|
1080
|
+
else {
|
|
1081
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, Visible) VALUES (?, ?, ?, '1,1', 1)").run(placementId, elementRefId, `${prefX},${prefY}`);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
else {
|
|
1085
|
+
// INVARIANT: every Button must have exactly one ElementPlacement
|
|
1086
|
+
// per PageLayout on its page. Snap renders buttons × layouts and
|
|
1087
|
+
// crashes if a (button, layout) pair is missing.
|
|
1088
|
+
//
|
|
1089
|
+
// Hidden placements (Visible=0) MUST use a position that doesn't
|
|
1090
|
+
// collide with an existing visible placement on that layout.
|
|
1091
|
+
// The existing convention puts hidden placements at out-of-grid
|
|
1092
|
+
// coordinates (e.g. position (2,4) on a 4×3 layout where row 4
|
|
1093
|
+
// doesn't exist). We do the same: synthesise (cols, 0) which is
|
|
1094
|
+
// guaranteed out of bounds since valid X is 0..cols-1.
|
|
1095
|
+
for (const info of layouts) {
|
|
1096
|
+
const cell = findFreeCell(info, prefX, prefY);
|
|
1097
|
+
const visible = cell !== null ? 1 : 0;
|
|
1098
|
+
const gridPosition = cell ?? `${info.cols},0`;
|
|
1099
|
+
const placementId = nextPlacementId++;
|
|
1100
|
+
if (hasPlacementPageLayoutId && hasPlacementVisible) {
|
|
1101
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId, Visible) VALUES (?, ?, ?, '1,1', ?, ?)").run(placementId, elementRefId, gridPosition, info.id, visible);
|
|
1102
|
+
}
|
|
1103
|
+
else if (hasPlacementPageLayoutId) {
|
|
1104
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId) VALUES (?, ?, ?, '1,1', ?)").run(placementId, elementRefId, gridPosition, info.id);
|
|
1105
|
+
}
|
|
1106
|
+
else if (hasPlacementVisible) {
|
|
1107
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, Visible) VALUES (?, ?, ?, '1,1', ?)").run(placementId, elementRefId, gridPosition, visible);
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan) VALUES (?, ?, ?, '1,1')").run(placementId, elementRefId, gridPosition);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
break;
|
|
1115
|
+
}
|
|
1116
|
+
case 'addWordListItem':
|
|
1117
|
+
case 'removeWordListItem':
|
|
1118
|
+
case 'clearWordList':
|
|
1119
|
+
// Snap has no WordList concept — these are no-ops by capability contract.
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
replay();
|
|
1126
|
+
}
|
|
1127
|
+
finally {
|
|
1128
|
+
db.close();
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
812
1131
|
async saveFromTree(tree, outputPath) {
|
|
813
1132
|
const { pathExists, mkDir, removePath, dirname } = this.options.fileAdapter;
|
|
814
1133
|
if (!isNodeRuntime()) {
|
|
@@ -266,8 +266,8 @@ class TouchChatProcessor extends BaseProcessor {
|
|
|
266
266
|
// Set button's x and y coordinates
|
|
267
267
|
button.x = absoluteX;
|
|
268
268
|
button.y = absoluteY;
|
|
269
|
-
// Add button to page
|
|
270
|
-
page.
|
|
269
|
+
// Add button to page (load path: do not record as a user mutation)
|
|
270
|
+
page._loadButton(button);
|
|
271
271
|
// Place button in grid (handle span)
|
|
272
272
|
for (let r = absoluteY; r < absoluteY + safeSpanY && r < 10; r++) {
|
|
273
273
|
for (let c = absoluteX; c < absoluteX + safeSpanX && c < 10; c++) {
|
|
@@ -368,7 +368,7 @@ class TouchChatProcessor extends BaseProcessor {
|
|
|
368
368
|
// Find the page that references this resource
|
|
369
369
|
const page = Object.values(tree.pages).find((p) => p.id === (numericToRid.get(btnRow.id) || String(btnRow.id)));
|
|
370
370
|
if (page)
|
|
371
|
-
page.
|
|
371
|
+
page._loadButton(button); // load path: do not record as a user mutation
|
|
372
372
|
});
|
|
373
373
|
}
|
|
374
374
|
catch (_e) {
|
|
@@ -249,6 +249,17 @@ export declare class AACPage {
|
|
|
249
249
|
scanType?: AACScanType;
|
|
250
250
|
});
|
|
251
251
|
addButton(button: AACButton): void;
|
|
252
|
+
/**
|
|
253
|
+
* Internal load-path button push: adds a button to the page WITHOUT recording a mutation.
|
|
254
|
+
* Used by processors during loadIntoTree so the loaded baseline isn't treated as user changes.
|
|
255
|
+
* Not part of the public API — consumers should always use addButton.
|
|
256
|
+
*/
|
|
257
|
+
_loadButton(button: AACButton): void;
|
|
258
|
+
/**
|
|
259
|
+
* Discard all recorded mutations on this page.
|
|
260
|
+
* Useful as an escape hatch after loadIntoTree if the consumer wants a clean baseline.
|
|
261
|
+
*/
|
|
262
|
+
clearMutations(): void;
|
|
252
263
|
/**
|
|
253
264
|
* Get the list of pending mutations for this page (read-only)
|
|
254
265
|
*/
|
|
@@ -205,6 +205,21 @@ class AACPage {
|
|
|
205
205
|
// Record the mutation
|
|
206
206
|
this._pendingMutations.push({ type: 'addButton', button });
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Internal load-path button push: adds a button to the page WITHOUT recording a mutation.
|
|
210
|
+
* Used by processors during loadIntoTree so the loaded baseline isn't treated as user changes.
|
|
211
|
+
* Not part of the public API — consumers should always use addButton.
|
|
212
|
+
*/
|
|
213
|
+
_loadButton(button) {
|
|
214
|
+
this.buttons.push(button);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Discard all recorded mutations on this page.
|
|
218
|
+
* Useful as an escape hatch after loadIntoTree if the consumer wants a clean baseline.
|
|
219
|
+
*/
|
|
220
|
+
clearMutations() {
|
|
221
|
+
this._pendingMutations = [];
|
|
222
|
+
}
|
|
208
223
|
/**
|
|
209
224
|
* Get the list of pending mutations for this page (read-only)
|
|
210
225
|
*/
|
|
@@ -225,7 +225,8 @@ class ApplePanelsProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
225
225
|
fontWeight: btn.DisplayImageWeight === 'bold' ? 'bold' : 'normal',
|
|
226
226
|
},
|
|
227
227
|
});
|
|
228
|
-
|
|
228
|
+
// Load path: do not record as a user mutation
|
|
229
|
+
page._loadButton(button);
|
|
229
230
|
if (btn.Rect) {
|
|
230
231
|
const rect = this.parseRect(btn.Rect);
|
|
231
232
|
if (rect) {
|
|
@@ -722,7 +722,8 @@ class AstericsGridProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
722
722
|
}
|
|
723
723
|
grid.gridElements.forEach((element) => {
|
|
724
724
|
const button = this.createButtonFromElement(element, colorConfig, activeColorSchemeDefinition);
|
|
725
|
-
|
|
725
|
+
// Load path: do not record as a user mutation
|
|
726
|
+
page._loadButton(button);
|
|
726
727
|
const buttonX = element.x || 0;
|
|
727
728
|
const buttonY = element.y || 0;
|
|
728
729
|
const buttonWidth = element.width || 1;
|
|
@@ -120,7 +120,8 @@ class DotProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
120
120
|
});
|
|
121
121
|
tree.addPage(page);
|
|
122
122
|
// Add a self button so single-node graphs yield one button
|
|
123
|
-
|
|
123
|
+
// Load path: do not record as a user mutation
|
|
124
|
+
page._loadButton(new treeStructure_1.AACButton({
|
|
124
125
|
id: `${node.id}_self`,
|
|
125
126
|
label: node.label,
|
|
126
127
|
message: node.label,
|
|
@@ -141,7 +142,8 @@ class DotProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
141
142
|
message: '',
|
|
142
143
|
targetPageId: edge.to,
|
|
143
144
|
});
|
|
144
|
-
|
|
145
|
+
// Load path: do not record as a user mutation
|
|
146
|
+
fromPage._loadButton(button);
|
|
145
147
|
}
|
|
146
148
|
}
|
|
147
149
|
return tree;
|
|
@@ -1597,8 +1597,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
1597
1597
|
...(imageData ? { imageData, image_id: resolvedImageEntry } : {}),
|
|
1598
1598
|
},
|
|
1599
1599
|
});
|
|
1600
|
-
// Add button to page
|
|
1601
|
-
page.
|
|
1600
|
+
// Add button to page (load path: do not record as a user mutation)
|
|
1601
|
+
page._loadButton(button);
|
|
1602
1602
|
// Place button in grid layout (handle colspan/rowspan)
|
|
1603
1603
|
for (let r = cellY; r < cellY + rowSpan && r < maxRows; r++) {
|
|
1604
1604
|
for (let c = cellX; c < cellX + colSpan && c < maxCols; c++) {
|
|
@@ -45,7 +45,8 @@ class OpmlProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
45
45
|
message: '',
|
|
46
46
|
targetPageId: childText.replace(/[^a-zA-Z0-9]/g, '_'),
|
|
47
47
|
});
|
|
48
|
-
|
|
48
|
+
// Load path: do not record as a user mutation
|
|
49
|
+
page._loadButton(button);
|
|
49
50
|
const { page: childPage, childPages: grandChildren } = this.processOutline(child, page.id);
|
|
50
51
|
if (childPage && childPage.id)
|
|
51
52
|
childPages.push(childPage, ...grandChildren);
|
|
@@ -18,6 +18,30 @@ declare class SnapProcessor extends BaseProcessor {
|
|
|
18
18
|
extractTexts(filePathOrBuffer: ProcessorInput): Promise<string[]>;
|
|
19
19
|
loadIntoTree(filePathOrBuffer: ProcessorInput): Promise<AACTree>;
|
|
20
20
|
processTexts(filePathOrBuffer: ProcessorInput, translations: Map<string, string>, outputPath: string): Promise<Uint8Array>;
|
|
21
|
+
/**
|
|
22
|
+
* Save a modified tree while preserving the original SQLite schema and data.
|
|
23
|
+
*
|
|
24
|
+
* Strategy: copy the original .sps verbatim, then open the copy and replay
|
|
25
|
+
* `page.pendingMutations` as targeted SQL UPDATE/INSERT statements. Everything
|
|
26
|
+
* not in the mutation log (PageLayout, ScanGroup, image blobs, ContentTypeData,
|
|
27
|
+
* ButtonPageLink, etc.) is preserved byte-for-byte from the original.
|
|
28
|
+
*
|
|
29
|
+
* This is the asset-preserving counterpart to `saveFromTree` (which builds a
|
|
30
|
+
* stripped-down DB from scratch and is unsuitable for round-tripping real
|
|
31
|
+
* TD Snap page sets).
|
|
32
|
+
*
|
|
33
|
+
* Supported mutations:
|
|
34
|
+
* - updateButton(id, patch) → UPDATE Button SET Label/Message WHERE Id = ?
|
|
35
|
+
* - removeButton(id) → UPDATE ElementPlacement SET Visible = 0 for all
|
|
36
|
+
* placements pointing at the button's ElementReference
|
|
37
|
+
* - addButton(button) → INSERT into ElementReference + Button + one
|
|
38
|
+
* ElementPlacement per existing PageLayout for
|
|
39
|
+
* the target page (so the button shows in every
|
|
40
|
+
* layout the user has). Image/audio not yet handled.
|
|
41
|
+
*
|
|
42
|
+
* WordList mutations are no-ops on Snap (capabilities.wordList === 'none').
|
|
43
|
+
*/
|
|
44
|
+
saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void>;
|
|
21
45
|
saveFromTree(tree: AACTree, outputPath: string): Promise<void>;
|
|
22
46
|
/**
|
|
23
47
|
* Add audio recording to a button in the database
|
|
@@ -42,7 +42,7 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
42
42
|
super(options);
|
|
43
43
|
this.capabilities = {
|
|
44
44
|
wordList: 'none',
|
|
45
|
-
preservesAssetsOnSave:
|
|
45
|
+
preservesAssetsOnSave: true,
|
|
46
46
|
newCellCreation: 'allowed',
|
|
47
47
|
};
|
|
48
48
|
this.symbolResolver = null;
|
|
@@ -582,7 +582,8 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
582
582
|
// Add to the intended parent page
|
|
583
583
|
const parentPage = tree.getPage(parentUniqueId);
|
|
584
584
|
if (parentPage) {
|
|
585
|
-
|
|
585
|
+
// Load path: do not record as a user mutation
|
|
586
|
+
parentPage._loadButton(button);
|
|
586
587
|
// Add button to grid layout if position data is available
|
|
587
588
|
const gridPositionStr = String(btnRow.GridPosition || '');
|
|
588
589
|
if (gridPositionStr && gridPositionStr.includes(',')) {
|
|
@@ -812,6 +813,324 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
812
813
|
await this.saveFromTree(tree, outputPath);
|
|
813
814
|
return await readBinaryFromInput(outputPath);
|
|
814
815
|
}
|
|
816
|
+
/**
|
|
817
|
+
* Save a modified tree while preserving the original SQLite schema and data.
|
|
818
|
+
*
|
|
819
|
+
* Strategy: copy the original .sps verbatim, then open the copy and replay
|
|
820
|
+
* `page.pendingMutations` as targeted SQL UPDATE/INSERT statements. Everything
|
|
821
|
+
* not in the mutation log (PageLayout, ScanGroup, image blobs, ContentTypeData,
|
|
822
|
+
* ButtonPageLink, etc.) is preserved byte-for-byte from the original.
|
|
823
|
+
*
|
|
824
|
+
* This is the asset-preserving counterpart to `saveFromTree` (which builds a
|
|
825
|
+
* stripped-down DB from scratch and is unsuitable for round-tripping real
|
|
826
|
+
* TD Snap page sets).
|
|
827
|
+
*
|
|
828
|
+
* Supported mutations:
|
|
829
|
+
* - updateButton(id, patch) → UPDATE Button SET Label/Message WHERE Id = ?
|
|
830
|
+
* - removeButton(id) → UPDATE ElementPlacement SET Visible = 0 for all
|
|
831
|
+
* placements pointing at the button's ElementReference
|
|
832
|
+
* - addButton(button) → INSERT into ElementReference + Button + one
|
|
833
|
+
* ElementPlacement per existing PageLayout for
|
|
834
|
+
* the target page (so the button shows in every
|
|
835
|
+
* layout the user has). Image/audio not yet handled.
|
|
836
|
+
*
|
|
837
|
+
* WordList mutations are no-ops on Snap (capabilities.wordList === 'none').
|
|
838
|
+
*/
|
|
839
|
+
async saveModifiedTree(originalPath, tree, outputPath) {
|
|
840
|
+
const { pathExists, mkDir, removePath, dirname, readBinaryFromInput, writeBinaryToPath } = this.options.fileAdapter;
|
|
841
|
+
if (!(0, io_1.isNodeRuntime)()) {
|
|
842
|
+
throw new Error('saveModifiedTree is only supported in Node.js for Snap files.');
|
|
843
|
+
}
|
|
844
|
+
const outputDir = dirname(outputPath);
|
|
845
|
+
if (!(await pathExists(outputDir))) {
|
|
846
|
+
await mkDir(outputDir, { recursive: true });
|
|
847
|
+
}
|
|
848
|
+
if (await pathExists(outputPath)) {
|
|
849
|
+
await removePath(outputPath);
|
|
850
|
+
}
|
|
851
|
+
// 1. Copy the original verbatim — preserves all 23+ tables, blobs, settings.
|
|
852
|
+
const originalBytes = await readBinaryFromInput(originalPath);
|
|
853
|
+
await writeBinaryToPath(outputPath, originalBytes);
|
|
854
|
+
// Short-circuit: if no page has any mutations, we're done.
|
|
855
|
+
const hasAnyMutations = Object.values(tree.pages).some((page) => page.pendingMutations.length > 0);
|
|
856
|
+
if (!hasAnyMutations) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
// 2. Open the copy.
|
|
860
|
+
const Database = (0, sqlite_1.requireBetterSqlite3)();
|
|
861
|
+
const db = new Database(outputPath, { readonly: false });
|
|
862
|
+
try {
|
|
863
|
+
// 3. Schema introspection — different Snap versions have different optional columns.
|
|
864
|
+
const tableColumns = (table) => {
|
|
865
|
+
try {
|
|
866
|
+
const rows = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
867
|
+
return new Set(rows.map((r) => r.name));
|
|
868
|
+
}
|
|
869
|
+
catch {
|
|
870
|
+
return new Set();
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
const buttonCols = tableColumns('Button');
|
|
874
|
+
const placementCols = tableColumns('ElementPlacement');
|
|
875
|
+
const hasPlacementVisible = placementCols.has('Visible');
|
|
876
|
+
const hasPlacementPageLayoutId = placementCols.has('PageLayoutId');
|
|
877
|
+
// 4. UniqueId → numeric Page.Id map.
|
|
878
|
+
const pageRows = db.prepare('SELECT Id, UniqueId FROM Page').all();
|
|
879
|
+
const uniqueIdToPageId = new Map();
|
|
880
|
+
for (const row of pageRows) {
|
|
881
|
+
if (row.UniqueId)
|
|
882
|
+
uniqueIdToPageId.set(String(row.UniqueId), row.Id);
|
|
883
|
+
// Allow lookup by stringified numeric Id too, since loadIntoTree falls back to it.
|
|
884
|
+
uniqueIdToPageId.set(String(row.Id), row.Id);
|
|
885
|
+
}
|
|
886
|
+
const layoutsByPage = new Map();
|
|
887
|
+
try {
|
|
888
|
+
const layoutRows = db
|
|
889
|
+
.prepare('SELECT Id, PageId, PageLayoutSetting FROM PageLayout')
|
|
890
|
+
.all();
|
|
891
|
+
// Pre-load occupied placements per layout so we can avoid (x,y) collisions.
|
|
892
|
+
const placementsByLayout = new Map();
|
|
893
|
+
const placementRows = db
|
|
894
|
+
.prepare('SELECT PageLayoutId, GridPosition FROM ElementPlacement WHERE GridPosition IS NOT NULL AND PageLayoutId IS NOT NULL')
|
|
895
|
+
.all();
|
|
896
|
+
for (const r of placementRows) {
|
|
897
|
+
let set = placementsByLayout.get(r.PageLayoutId);
|
|
898
|
+
if (!set) {
|
|
899
|
+
set = new Set();
|
|
900
|
+
placementsByLayout.set(r.PageLayoutId, set);
|
|
901
|
+
}
|
|
902
|
+
set.add(r.GridPosition);
|
|
903
|
+
}
|
|
904
|
+
for (const row of layoutRows) {
|
|
905
|
+
const parts = String(row.PageLayoutSetting ?? '').split(',');
|
|
906
|
+
const cols = parseInt(parts[0], 10) || 4;
|
|
907
|
+
const rows = parseInt(parts[1], 10) || 4;
|
|
908
|
+
const info = {
|
|
909
|
+
id: row.Id,
|
|
910
|
+
cols,
|
|
911
|
+
rows,
|
|
912
|
+
occupied: placementsByLayout.get(row.Id) ?? new Set(),
|
|
913
|
+
};
|
|
914
|
+
const list = layoutsByPage.get(row.PageId);
|
|
915
|
+
if (list)
|
|
916
|
+
list.push(info);
|
|
917
|
+
else
|
|
918
|
+
layoutsByPage.set(row.PageId, [info]);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
// PageLayout table may not exist on older schemas — placements get NULL PageLayoutId.
|
|
923
|
+
}
|
|
924
|
+
// Find first empty cell on a layout, starting from a preferred (x,y).
|
|
925
|
+
// Returns null if the layout is fully occupied.
|
|
926
|
+
const findFreeCell = (info, prefX, prefY) => {
|
|
927
|
+
const inBounds = (x, y) => x >= 0 && x < info.cols && y >= 0 && y < info.rows;
|
|
928
|
+
if (inBounds(prefX, prefY)) {
|
|
929
|
+
const key = `${prefX},${prefY}`;
|
|
930
|
+
if (!info.occupied.has(key)) {
|
|
931
|
+
info.occupied.add(key);
|
|
932
|
+
return key;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
for (let y = 0; y < info.rows; y++) {
|
|
936
|
+
for (let x = 0; x < info.cols; x++) {
|
|
937
|
+
const key = `${x},${y}`;
|
|
938
|
+
if (!info.occupied.has(key)) {
|
|
939
|
+
info.occupied.add(key);
|
|
940
|
+
return key;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return null;
|
|
945
|
+
};
|
|
946
|
+
// Generate a UUID for new Button.UniqueId. Required: Node has crypto.randomUUID since 14.17.
|
|
947
|
+
const nodeCrypto = (0, io_1.getNodeRequire)()?.('crypto');
|
|
948
|
+
const uuid = () => nodeCrypto?.randomUUID
|
|
949
|
+
? nodeCrypto.randomUUID()
|
|
950
|
+
: // Fallback (very unlikely path, but shouldn't break older Nodes)
|
|
951
|
+
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
952
|
+
const r = (Math.random() * 16) | 0;
|
|
953
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
954
|
+
return v.toString(16);
|
|
955
|
+
});
|
|
956
|
+
// 5. Next-available IDs for inserts.
|
|
957
|
+
const nextId = (table) => {
|
|
958
|
+
const row = db.prepare(`SELECT COALESCE(MAX(Id), 0) AS maxId FROM ${table}`).get();
|
|
959
|
+
return (row.maxId || 0) + 1;
|
|
960
|
+
};
|
|
961
|
+
let nextButtonId = nextId('Button');
|
|
962
|
+
let nextElementRefId = nextId('ElementReference');
|
|
963
|
+
let nextPlacementId = nextId('ElementPlacement');
|
|
964
|
+
// CommandSequence is optional but TD Snap crashes on some pages (e.g. dashboards
|
|
965
|
+
// like "Google Home Speaker") when a button has no CommandSequence row. Every
|
|
966
|
+
// button in the original DB has one. We track this only if the table exists.
|
|
967
|
+
const hasCommandSequence = tableColumns('CommandSequence').size > 0;
|
|
968
|
+
let nextCommandSequenceId = hasCommandSequence ? nextId('CommandSequence') : 0;
|
|
969
|
+
// 6. Replay mutations inside one transaction for atomicity + speed.
|
|
970
|
+
const replay = db.transaction(() => {
|
|
971
|
+
for (const page of Object.values(tree.pages)) {
|
|
972
|
+
if (page.pendingMutations.length === 0)
|
|
973
|
+
continue;
|
|
974
|
+
const numericPageId = uniqueIdToPageId.get(String(page.id));
|
|
975
|
+
if (numericPageId === undefined) {
|
|
976
|
+
// eslint-disable-next-line no-console
|
|
977
|
+
console.warn(`[Snap] saveModifiedTree: page "${page.name}" (${page.id}) not found in original DB; skipping ${page.pendingMutations.length} mutation(s)`);
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
for (const mutation of page.pendingMutations) {
|
|
981
|
+
switch (mutation.type) {
|
|
982
|
+
case 'updateButton': {
|
|
983
|
+
const sets = [];
|
|
984
|
+
const args = [];
|
|
985
|
+
if (mutation.patch.label !== undefined && buttonCols.has('Label')) {
|
|
986
|
+
sets.push('Label = ?');
|
|
987
|
+
args.push(mutation.patch.label);
|
|
988
|
+
}
|
|
989
|
+
if (mutation.patch.message !== undefined && buttonCols.has('Message')) {
|
|
990
|
+
sets.push('Message = ?');
|
|
991
|
+
args.push(mutation.patch.message);
|
|
992
|
+
}
|
|
993
|
+
if (sets.length === 0)
|
|
994
|
+
break;
|
|
995
|
+
args.push(Number(mutation.buttonId));
|
|
996
|
+
db.prepare(`UPDATE Button SET ${sets.join(', ')} WHERE Id = ?`).run(...args);
|
|
997
|
+
break;
|
|
998
|
+
}
|
|
999
|
+
case 'removeButton': {
|
|
1000
|
+
// Hide all placements that reference the button's ElementReference.
|
|
1001
|
+
const buttonRow = db
|
|
1002
|
+
.prepare('SELECT ElementReferenceId FROM Button WHERE Id = ?')
|
|
1003
|
+
.get(Number(mutation.buttonId));
|
|
1004
|
+
if (!buttonRow || buttonRow.ElementReferenceId == null)
|
|
1005
|
+
break;
|
|
1006
|
+
if (hasPlacementVisible) {
|
|
1007
|
+
db.prepare('UPDATE ElementPlacement SET Visible = 0 WHERE ElementReferenceId = ?').run(buttonRow.ElementReferenceId);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
// Older schemas without Visible: delete the placements outright.
|
|
1011
|
+
db.prepare('DELETE FROM ElementPlacement WHERE ElementReferenceId = ?').run(buttonRow.ElementReferenceId);
|
|
1012
|
+
}
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
case 'addButton': {
|
|
1016
|
+
const button = mutation.button;
|
|
1017
|
+
const elementRefId = nextElementRefId++;
|
|
1018
|
+
const buttonId = nextButtonId++;
|
|
1019
|
+
// ElementReference: TD Snap requires ElementType, ForegroundColor,
|
|
1020
|
+
// BackgroundColor, and AudioCueRecordingId to be non-NULL on render
|
|
1021
|
+
// — NULL values crash dashboard pages (e.g. "Google Home Speaker").
|
|
1022
|
+
// Defaults below match the modal values across real Snap files
|
|
1023
|
+
// (>99% of existing rows in Core First Scanning use them).
|
|
1024
|
+
const erColumns = tableColumns('ElementReference');
|
|
1025
|
+
const erCandidates = [
|
|
1026
|
+
{ col: 'Id', value: elementRefId },
|
|
1027
|
+
{ col: 'PageId', value: numericPageId },
|
|
1028
|
+
{ col: 'ElementType', value: 0 }, // 0 = button; only nonzero in 1/20608 rows
|
|
1029
|
+
{ col: 'ForegroundColor', value: -14934754 }, // dark text default (99.8% of rows)
|
|
1030
|
+
{ col: 'BackgroundColor', value: -132102 }, // light cell default (85.7% of rows)
|
|
1031
|
+
{ col: 'AudioCueRecordingId', value: 0 }, // 0 = no audio cue (99.99% of rows)
|
|
1032
|
+
];
|
|
1033
|
+
const erFieldsPresent = erCandidates.filter((f) => erColumns.has(f.col));
|
|
1034
|
+
db.prepare(`INSERT INTO ElementReference (${erFieldsPresent
|
|
1035
|
+
.map((f) => f.col)
|
|
1036
|
+
.join(', ')}) VALUES (${erFieldsPresent.map(() => '?').join(', ')})`).run(...erFieldsPresent.map((f) => f.value));
|
|
1037
|
+
// Button: provide non-NULL defaults for columns TD Snap reads.
|
|
1038
|
+
// ContentType = 6 (normal speak button), CommandFlags = 8 (standard),
|
|
1039
|
+
// LabelOwnership / ImageOwnership = 3 (owned by this page set),
|
|
1040
|
+
// ActiveContentType = 0, BorderThickness = 0, UniqueId = fresh GUID,
|
|
1041
|
+
// image / sound / symbol IDs = 0 (= "no asset").
|
|
1042
|
+
const candidateFields = [
|
|
1043
|
+
{ col: 'Id', value: buttonId },
|
|
1044
|
+
{ col: 'Label', value: button.label || '' },
|
|
1045
|
+
{ col: 'Message', value: button.message || button.label || '' },
|
|
1046
|
+
{ col: 'ElementReferenceId', value: elementRefId },
|
|
1047
|
+
{ col: 'ContentType', value: 6 },
|
|
1048
|
+
{ col: 'CommandFlags', value: 8 },
|
|
1049
|
+
{ col: 'LabelOwnership', value: 3 },
|
|
1050
|
+
{ col: 'ImageOwnership', value: 3 },
|
|
1051
|
+
{ col: 'ActiveContentType', value: 0 },
|
|
1052
|
+
{ col: 'BorderThickness', value: 0 },
|
|
1053
|
+
{ col: 'UniqueId', value: uuid() },
|
|
1054
|
+
{ col: 'LibrarySymbolId', value: 0 },
|
|
1055
|
+
{ col: 'PageSetImageId', value: 0 },
|
|
1056
|
+
{ col: 'MessageRecordingId', value: 0 },
|
|
1057
|
+
// UseMessageRecording: omit. 99.99% of existing rows have NULL,
|
|
1058
|
+
// and forcing 0 makes Sarah/Mum the only outliers in the DB.
|
|
1059
|
+
{ col: 'SymbolColorDataId', value: 0 },
|
|
1060
|
+
];
|
|
1061
|
+
const presentFields = candidateFields.filter((f) => buttonCols.has(f.col));
|
|
1062
|
+
const sql = `INSERT INTO Button (${presentFields
|
|
1063
|
+
.map((f) => f.col)
|
|
1064
|
+
.join(', ')}) VALUES (${presentFields.map(() => '?').join(', ')})`;
|
|
1065
|
+
db.prepare(sql).run(...presentFields.map((f) => f.value));
|
|
1066
|
+
// Insert a default CommandSequence row that explicitly speaks the
|
|
1067
|
+
// button's Label ($type:3 = MessageAction, value 0 = speak Label).
|
|
1068
|
+
// This is the most common pattern in real Snap files (9514 / 19402
|
|
1069
|
+
// buttons in Core First Scanning). Without an explicit action,
|
|
1070
|
+
// dashboard pages (e.g. "Google Home Speaker") crash on render —
|
|
1071
|
+
// empty $values is only used for hidden/help-text buttons.
|
|
1072
|
+
if (hasCommandSequence) {
|
|
1073
|
+
db.prepare('INSERT INTO CommandSequence (Id, SerializedCommands, ButtonId) VALUES (?, ?, ?)').run(nextCommandSequenceId++, '{"$type":"1","$values":[{"$type":"3","MessageAction":0}]}', buttonId);
|
|
1074
|
+
}
|
|
1075
|
+
const layouts = layoutsByPage.get(numericPageId) ?? [];
|
|
1076
|
+
const prefX = button.x ?? 0;
|
|
1077
|
+
const prefY = button.y ?? 0;
|
|
1078
|
+
if (layouts.length === 0) {
|
|
1079
|
+
const placementId = nextPlacementId++;
|
|
1080
|
+
if (hasPlacementPageLayoutId) {
|
|
1081
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId, Visible) VALUES (?, ?, ?, '1,1', NULL, 1)").run(placementId, elementRefId, `${prefX},${prefY}`);
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, Visible) VALUES (?, ?, ?, '1,1', 1)").run(placementId, elementRefId, `${prefX},${prefY}`);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
else {
|
|
1088
|
+
// INVARIANT: every Button must have exactly one ElementPlacement
|
|
1089
|
+
// per PageLayout on its page. Snap renders buttons × layouts and
|
|
1090
|
+
// crashes if a (button, layout) pair is missing.
|
|
1091
|
+
//
|
|
1092
|
+
// Hidden placements (Visible=0) MUST use a position that doesn't
|
|
1093
|
+
// collide with an existing visible placement on that layout.
|
|
1094
|
+
// The existing convention puts hidden placements at out-of-grid
|
|
1095
|
+
// coordinates (e.g. position (2,4) on a 4×3 layout where row 4
|
|
1096
|
+
// doesn't exist). We do the same: synthesise (cols, 0) which is
|
|
1097
|
+
// guaranteed out of bounds since valid X is 0..cols-1.
|
|
1098
|
+
for (const info of layouts) {
|
|
1099
|
+
const cell = findFreeCell(info, prefX, prefY);
|
|
1100
|
+
const visible = cell !== null ? 1 : 0;
|
|
1101
|
+
const gridPosition = cell ?? `${info.cols},0`;
|
|
1102
|
+
const placementId = nextPlacementId++;
|
|
1103
|
+
if (hasPlacementPageLayoutId && hasPlacementVisible) {
|
|
1104
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId, Visible) VALUES (?, ?, ?, '1,1', ?, ?)").run(placementId, elementRefId, gridPosition, info.id, visible);
|
|
1105
|
+
}
|
|
1106
|
+
else if (hasPlacementPageLayoutId) {
|
|
1107
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId) VALUES (?, ?, ?, '1,1', ?)").run(placementId, elementRefId, gridPosition, info.id);
|
|
1108
|
+
}
|
|
1109
|
+
else if (hasPlacementVisible) {
|
|
1110
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, Visible) VALUES (?, ?, ?, '1,1', ?)").run(placementId, elementRefId, gridPosition, visible);
|
|
1111
|
+
}
|
|
1112
|
+
else {
|
|
1113
|
+
db.prepare("INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan) VALUES (?, ?, ?, '1,1')").run(placementId, elementRefId, gridPosition);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
break;
|
|
1118
|
+
}
|
|
1119
|
+
case 'addWordListItem':
|
|
1120
|
+
case 'removeWordListItem':
|
|
1121
|
+
case 'clearWordList':
|
|
1122
|
+
// Snap has no WordList concept — these are no-ops by capability contract.
|
|
1123
|
+
break;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
replay();
|
|
1129
|
+
}
|
|
1130
|
+
finally {
|
|
1131
|
+
db.close();
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
815
1134
|
async saveFromTree(tree, outputPath) {
|
|
816
1135
|
const { pathExists, mkDir, removePath, dirname } = this.options.fileAdapter;
|
|
817
1136
|
if (!(0, io_1.isNodeRuntime)()) {
|
|
@@ -269,8 +269,8 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
269
269
|
// Set button's x and y coordinates
|
|
270
270
|
button.x = absoluteX;
|
|
271
271
|
button.y = absoluteY;
|
|
272
|
-
// Add button to page
|
|
273
|
-
page.
|
|
272
|
+
// Add button to page (load path: do not record as a user mutation)
|
|
273
|
+
page._loadButton(button);
|
|
274
274
|
// Place button in grid (handle span)
|
|
275
275
|
for (let r = absoluteY; r < absoluteY + safeSpanY && r < 10; r++) {
|
|
276
276
|
for (let c = absoluteX; c < absoluteX + safeSpanX && c < 10; c++) {
|
|
@@ -371,7 +371,7 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
371
371
|
// Find the page that references this resource
|
|
372
372
|
const page = Object.values(tree.pages).find((p) => p.id === (numericToRid.get(btnRow.id) || String(btnRow.id)));
|
|
373
373
|
if (page)
|
|
374
|
-
page.
|
|
374
|
+
page._loadButton(button); // load path: do not record as a user mutation
|
|
375
375
|
});
|
|
376
376
|
}
|
|
377
377
|
catch (_e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@willwade/aac-processors",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
4
4
|
"description": "A comprehensive TypeScript library for processing AAC (Augmentative and Alternative Communication) file formats with translation support",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"browser": "dist/browser/index.browser.js",
|