@willwade/aac-processors 0.2.14 → 0.2.16
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/processors/gridsetProcessor.js +19 -22
- package/dist/browser/processors/obfProcessor.js +15 -28
- package/dist/browser/processors/snapProcessor.js +319 -1
- package/dist/processors/gridsetProcessor.js +19 -45
- package/dist/processors/obfProcessor.js +15 -51
- package/dist/processors/snapProcessor.d.ts +24 -0
- package/dist/processors/snapProcessor.js +319 -1
- package/package.json +1 -1
|
@@ -2227,9 +2227,8 @@ class GridsetProcessor extends BaseProcessor {
|
|
|
2227
2227
|
await writeBinaryToPath(outputPath, originalBuffer);
|
|
2228
2228
|
return;
|
|
2229
2229
|
}
|
|
2230
|
-
const
|
|
2231
|
-
const
|
|
2232
|
-
const outputZip = new AdmZip();
|
|
2230
|
+
const originalZip = await this.options.zipAdapter(originalPath);
|
|
2231
|
+
const outputZip = await this.options.zipAdapter();
|
|
2233
2232
|
// Check if any page has pending mutations
|
|
2234
2233
|
const hasPendingMutations = Object.values(tree.pages).some((page) => page.pendingMutations.length > 0);
|
|
2235
2234
|
if (hasPendingMutations) {
|
|
@@ -2246,15 +2245,13 @@ class GridsetProcessor extends BaseProcessor {
|
|
|
2246
2245
|
suppressBooleanAttributes: false,
|
|
2247
2246
|
});
|
|
2248
2247
|
GridsetSaveHandler.saveWithMutations(tree, originalZip, outputZip, parser, gridBuilder, (page) => this.createBasicGridXml(page));
|
|
2249
|
-
// Copy
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
outputZip.addFile(entry.entryName, entry.getData());
|
|
2255
|
-
}
|
|
2248
|
+
// Copy files
|
|
2249
|
+
const outputFiles = [];
|
|
2250
|
+
for (const name of originalZip.listFiles()) {
|
|
2251
|
+
const data = await originalZip.readFile(name);
|
|
2252
|
+
outputFiles.push({ name, data });
|
|
2256
2253
|
}
|
|
2257
|
-
const outputBuffer = outputZip.
|
|
2254
|
+
const outputBuffer = await outputZip.writeFiles(outputFiles);
|
|
2258
2255
|
await writeBinaryToPath(outputPath, outputBuffer);
|
|
2259
2256
|
return;
|
|
2260
2257
|
}
|
|
@@ -2285,15 +2282,15 @@ class GridsetProcessor extends BaseProcessor {
|
|
|
2285
2282
|
const gridPath = `Grids/${page.name}/grid.xml`;
|
|
2286
2283
|
modifiedGridFiles.add(gridPath);
|
|
2287
2284
|
// Try to get the original grid.xml file
|
|
2288
|
-
const
|
|
2289
|
-
if (!
|
|
2285
|
+
const originalEntries = originalZip.listFiles();
|
|
2286
|
+
if (!originalEntries.includes(gridPath)) {
|
|
2290
2287
|
// If original doesn't exist, create a new basic grid
|
|
2291
2288
|
const basicGrid = this.createBasicGridXml(page);
|
|
2292
2289
|
newGridFiles.set(gridPath, basicGrid);
|
|
2293
2290
|
continue;
|
|
2294
2291
|
}
|
|
2295
2292
|
// Parse the original grid XML
|
|
2296
|
-
const originalContent =
|
|
2293
|
+
const originalContent = (await originalZip.readFile(gridPath)).toString();
|
|
2297
2294
|
const originalGrid = parser.parse(originalContent);
|
|
2298
2295
|
if (!originalGrid.Grid) {
|
|
2299
2296
|
// Invalid grid structure, create a basic one
|
|
@@ -2458,22 +2455,22 @@ class GridsetProcessor extends BaseProcessor {
|
|
|
2458
2455
|
newGridFiles.set(gridPath, builtXml);
|
|
2459
2456
|
}
|
|
2460
2457
|
// Copy all files from original zip, replacing modified grid files
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
continue;
|
|
2458
|
+
const outputFiles = [];
|
|
2459
|
+
for (const entry of originalZip.listFiles()) {
|
|
2464
2460
|
// Skip grid.xml files that we're modifying
|
|
2465
|
-
if (modifiedGridFiles.has(entry
|
|
2466
|
-
const newContent = newGridFiles.get(entry
|
|
2461
|
+
if (modifiedGridFiles.has(entry)) {
|
|
2462
|
+
const newContent = newGridFiles.get(entry);
|
|
2467
2463
|
if (newContent) {
|
|
2468
|
-
|
|
2464
|
+
outputFiles.push({ name: entry, data: Buffer.from(newContent, 'utf8') });
|
|
2469
2465
|
}
|
|
2470
2466
|
continue;
|
|
2471
2467
|
}
|
|
2472
2468
|
// Copy all other files as-is
|
|
2473
|
-
|
|
2469
|
+
const data = await originalZip.readFile(entry);
|
|
2470
|
+
outputFiles.push({ name: entry, data });
|
|
2474
2471
|
}
|
|
2475
2472
|
// Write the output ZIP
|
|
2476
|
-
const outputBuffer = outputZip.
|
|
2473
|
+
const outputBuffer = await outputZip.writeFiles(outputFiles);
|
|
2477
2474
|
await writeBinaryToPath(outputPath, outputBuffer);
|
|
2478
2475
|
}
|
|
2479
2476
|
/**
|
|
@@ -741,24 +741,18 @@ class ObfProcessor extends BaseProcessor {
|
|
|
741
741
|
await writeBinaryToPath(outputPath, originalBuffer);
|
|
742
742
|
return;
|
|
743
743
|
}
|
|
744
|
-
const
|
|
745
|
-
const
|
|
746
|
-
const
|
|
747
|
-
// Track which .obf files we're modifying
|
|
748
|
-
const modifiedObfFiles = new Set();
|
|
749
|
-
// Generate new .obf files for pages in the tree
|
|
750
|
-
const newObfFiles = new Map();
|
|
744
|
+
const originalZip = await this.options.zipAdapter(originalPath);
|
|
745
|
+
const outputZip = await this.options.zipAdapter();
|
|
746
|
+
const outputFiles = [];
|
|
751
747
|
for (const page of Object.values(tree.pages)) {
|
|
752
|
-
const
|
|
753
|
-
modifiedObfFiles.add(obfFilename);
|
|
748
|
+
const name = this.getPageFilename(page.id, tree.metadata);
|
|
754
749
|
// createObfBoardFromPage will automatically apply mutations if present
|
|
755
750
|
const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
|
|
756
|
-
const
|
|
757
|
-
|
|
751
|
+
const data = JSON.stringify(obfBoard, null, 2);
|
|
752
|
+
outputFiles.push({ name, data });
|
|
758
753
|
}
|
|
759
754
|
// Generate updated manifest if we have pages
|
|
760
755
|
if (Object.keys(tree.pages).length > 0) {
|
|
761
|
-
modifiedObfFiles.add('manifest.json');
|
|
762
756
|
const manifest = {
|
|
763
757
|
format: OBF_FORMAT_VERSION,
|
|
764
758
|
root: tree.metadata.defaultHomePageId,
|
|
@@ -771,25 +765,18 @@ class ObfProcessor extends BaseProcessor {
|
|
|
771
765
|
sounds: {},
|
|
772
766
|
},
|
|
773
767
|
};
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
const newContent = newObfFiles.get(entry.entryName);
|
|
783
|
-
if (newContent) {
|
|
784
|
-
outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
|
|
785
|
-
}
|
|
786
|
-
continue;
|
|
768
|
+
const data = Buffer.from(JSON.stringify(manifest), 'utf8');
|
|
769
|
+
outputFiles.push({ name: 'manifest.json', data });
|
|
770
|
+
}
|
|
771
|
+
// Add remaining .obf from original zip
|
|
772
|
+
for (const entry of originalZip.listFiles()) {
|
|
773
|
+
if (!outputFiles.find((file) => file.name === entry)) {
|
|
774
|
+
const data = await originalZip.readFile(entry);
|
|
775
|
+
outputFiles.push({ name: entry, data });
|
|
787
776
|
}
|
|
788
|
-
// Copy all other files as-is (preserves images, sounds, etc.)
|
|
789
|
-
outputZip.addFile(entry.entryName, entry.getData());
|
|
790
777
|
}
|
|
791
778
|
// Write the output ZIP
|
|
792
|
-
const outputBuffer = outputZip.
|
|
779
|
+
const outputBuffer = await outputZip.writeFiles(outputFiles);
|
|
793
780
|
await writeBinaryToPath(outputPath, outputBuffer);
|
|
794
781
|
}
|
|
795
782
|
/**
|
|
@@ -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;
|
|
@@ -810,6 +810,324 @@ class SnapProcessor extends BaseProcessor {
|
|
|
810
810
|
await this.saveFromTree(tree, outputPath);
|
|
811
811
|
return await readBinaryFromInput(outputPath);
|
|
812
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
|
+
}
|
|
813
1131
|
async saveFromTree(tree, outputPath) {
|
|
814
1132
|
const { pathExists, mkDir, removePath, dirname } = this.options.fileAdapter;
|
|
815
1133
|
if (!isNodeRuntime()) {
|
|
@@ -1,27 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
-
if (mod && mod.__esModule) return mod;
|
|
20
|
-
var result = {};
|
|
21
|
-
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
-
__setModuleDefault(result, mod);
|
|
23
|
-
return result;
|
|
24
|
-
};
|
|
25
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
3
|
exports.GridsetProcessor = void 0;
|
|
27
4
|
const baseProcessor_1 = require("../core/baseProcessor");
|
|
@@ -2253,9 +2230,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
2253
2230
|
await writeBinaryToPath(outputPath, originalBuffer);
|
|
2254
2231
|
return;
|
|
2255
2232
|
}
|
|
2256
|
-
const
|
|
2257
|
-
const
|
|
2258
|
-
const outputZip = new AdmZip();
|
|
2233
|
+
const originalZip = await this.options.zipAdapter(originalPath);
|
|
2234
|
+
const outputZip = await this.options.zipAdapter();
|
|
2259
2235
|
// Check if any page has pending mutations
|
|
2260
2236
|
const hasPendingMutations = Object.values(tree.pages).some((page) => page.pendingMutations.length > 0);
|
|
2261
2237
|
if (hasPendingMutations) {
|
|
@@ -2272,15 +2248,13 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
2272
2248
|
suppressBooleanAttributes: false,
|
|
2273
2249
|
});
|
|
2274
2250
|
saveMutations_1.GridsetSaveHandler.saveWithMutations(tree, originalZip, outputZip, parser, gridBuilder, (page) => this.createBasicGridXml(page));
|
|
2275
|
-
// Copy
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
outputZip.addFile(entry.entryName, entry.getData());
|
|
2281
|
-
}
|
|
2251
|
+
// Copy files
|
|
2252
|
+
const outputFiles = [];
|
|
2253
|
+
for (const name of originalZip.listFiles()) {
|
|
2254
|
+
const data = await originalZip.readFile(name);
|
|
2255
|
+
outputFiles.push({ name, data });
|
|
2282
2256
|
}
|
|
2283
|
-
const outputBuffer = outputZip.
|
|
2257
|
+
const outputBuffer = await outputZip.writeFiles(outputFiles);
|
|
2284
2258
|
await writeBinaryToPath(outputPath, outputBuffer);
|
|
2285
2259
|
return;
|
|
2286
2260
|
}
|
|
@@ -2311,15 +2285,15 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
2311
2285
|
const gridPath = `Grids/${page.name}/grid.xml`;
|
|
2312
2286
|
modifiedGridFiles.add(gridPath);
|
|
2313
2287
|
// Try to get the original grid.xml file
|
|
2314
|
-
const
|
|
2315
|
-
if (!
|
|
2288
|
+
const originalEntries = originalZip.listFiles();
|
|
2289
|
+
if (!originalEntries.includes(gridPath)) {
|
|
2316
2290
|
// If original doesn't exist, create a new basic grid
|
|
2317
2291
|
const basicGrid = this.createBasicGridXml(page);
|
|
2318
2292
|
newGridFiles.set(gridPath, basicGrid);
|
|
2319
2293
|
continue;
|
|
2320
2294
|
}
|
|
2321
2295
|
// Parse the original grid XML
|
|
2322
|
-
const originalContent =
|
|
2296
|
+
const originalContent = (await originalZip.readFile(gridPath)).toString();
|
|
2323
2297
|
const originalGrid = parser.parse(originalContent);
|
|
2324
2298
|
if (!originalGrid.Grid) {
|
|
2325
2299
|
// Invalid grid structure, create a basic one
|
|
@@ -2484,22 +2458,22 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
2484
2458
|
newGridFiles.set(gridPath, builtXml);
|
|
2485
2459
|
}
|
|
2486
2460
|
// Copy all files from original zip, replacing modified grid files
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
continue;
|
|
2461
|
+
const outputFiles = [];
|
|
2462
|
+
for (const entry of originalZip.listFiles()) {
|
|
2490
2463
|
// Skip grid.xml files that we're modifying
|
|
2491
|
-
if (modifiedGridFiles.has(entry
|
|
2492
|
-
const newContent = newGridFiles.get(entry
|
|
2464
|
+
if (modifiedGridFiles.has(entry)) {
|
|
2465
|
+
const newContent = newGridFiles.get(entry);
|
|
2493
2466
|
if (newContent) {
|
|
2494
|
-
|
|
2467
|
+
outputFiles.push({ name: entry, data: Buffer.from(newContent, 'utf8') });
|
|
2495
2468
|
}
|
|
2496
2469
|
continue;
|
|
2497
2470
|
}
|
|
2498
2471
|
// Copy all other files as-is
|
|
2499
|
-
|
|
2472
|
+
const data = await originalZip.readFile(entry);
|
|
2473
|
+
outputFiles.push({ name: entry, data });
|
|
2500
2474
|
}
|
|
2501
2475
|
// Write the output ZIP
|
|
2502
|
-
const outputBuffer = outputZip.
|
|
2476
|
+
const outputBuffer = await outputZip.writeFiles(outputFiles);
|
|
2503
2477
|
await writeBinaryToPath(outputPath, outputBuffer);
|
|
2504
2478
|
}
|
|
2505
2479
|
/**
|
|
@@ -1,27 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || function (mod) {
|
|
19
|
-
if (mod && mod.__esModule) return mod;
|
|
20
|
-
var result = {};
|
|
21
|
-
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
22
|
-
__setModuleDefault(result, mod);
|
|
23
|
-
return result;
|
|
24
|
-
};
|
|
25
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
3
|
exports.ObfProcessor = void 0;
|
|
27
4
|
const baseProcessor_1 = require("../core/baseProcessor");
|
|
@@ -767,24 +744,18 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
767
744
|
await writeBinaryToPath(outputPath, originalBuffer);
|
|
768
745
|
return;
|
|
769
746
|
}
|
|
770
|
-
const
|
|
771
|
-
const
|
|
772
|
-
const
|
|
773
|
-
// Track which .obf files we're modifying
|
|
774
|
-
const modifiedObfFiles = new Set();
|
|
775
|
-
// Generate new .obf files for pages in the tree
|
|
776
|
-
const newObfFiles = new Map();
|
|
747
|
+
const originalZip = await this.options.zipAdapter(originalPath);
|
|
748
|
+
const outputZip = await this.options.zipAdapter();
|
|
749
|
+
const outputFiles = [];
|
|
777
750
|
for (const page of Object.values(tree.pages)) {
|
|
778
|
-
const
|
|
779
|
-
modifiedObfFiles.add(obfFilename);
|
|
751
|
+
const name = this.getPageFilename(page.id, tree.metadata);
|
|
780
752
|
// createObfBoardFromPage will automatically apply mutations if present
|
|
781
753
|
const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
|
|
782
|
-
const
|
|
783
|
-
|
|
754
|
+
const data = JSON.stringify(obfBoard, null, 2);
|
|
755
|
+
outputFiles.push({ name, data });
|
|
784
756
|
}
|
|
785
757
|
// Generate updated manifest if we have pages
|
|
786
758
|
if (Object.keys(tree.pages).length > 0) {
|
|
787
|
-
modifiedObfFiles.add('manifest.json');
|
|
788
759
|
const manifest = {
|
|
789
760
|
format: OBF_FORMAT_VERSION,
|
|
790
761
|
root: tree.metadata.defaultHomePageId,
|
|
@@ -797,25 +768,18 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
797
768
|
sounds: {},
|
|
798
769
|
},
|
|
799
770
|
};
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const newContent = newObfFiles.get(entry.entryName);
|
|
809
|
-
if (newContent) {
|
|
810
|
-
outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
|
|
811
|
-
}
|
|
812
|
-
continue;
|
|
771
|
+
const data = Buffer.from(JSON.stringify(manifest), 'utf8');
|
|
772
|
+
outputFiles.push({ name: 'manifest.json', data });
|
|
773
|
+
}
|
|
774
|
+
// Add remaining .obf from original zip
|
|
775
|
+
for (const entry of originalZip.listFiles()) {
|
|
776
|
+
if (!outputFiles.find((file) => file.name === entry)) {
|
|
777
|
+
const data = await originalZip.readFile(entry);
|
|
778
|
+
outputFiles.push({ name: entry, data });
|
|
813
779
|
}
|
|
814
|
-
// Copy all other files as-is (preserves images, sounds, etc.)
|
|
815
|
-
outputZip.addFile(entry.entryName, entry.getData());
|
|
816
780
|
}
|
|
817
781
|
// Write the output ZIP
|
|
818
|
-
const outputBuffer = outputZip.
|
|
782
|
+
const outputBuffer = await outputZip.writeFiles(outputFiles);
|
|
819
783
|
await writeBinaryToPath(outputPath, outputBuffer);
|
|
820
784
|
}
|
|
821
785
|
/**
|
|
@@ -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;
|
|
@@ -813,6 +813,324 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
|
|
|
813
813
|
await this.saveFromTree(tree, outputPath);
|
|
814
814
|
return await readBinaryFromInput(outputPath);
|
|
815
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
|
+
}
|
|
816
1134
|
async saveFromTree(tree, outputPath) {
|
|
817
1135
|
const { pathExists, mkDir, removePath, dirname } = this.options.fileAdapter;
|
|
818
1136
|
if (!(0, io_1.isNodeRuntime)()) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@willwade/aac-processors",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.16",
|
|
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",
|