@willwade/aac-processors 0.2.14 → 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.
@@ -39,7 +39,7 @@ class SnapProcessor extends BaseProcessor {
39
39
  super(options);
40
40
  this.capabilities = {
41
41
  wordList: 'none',
42
- preservesAssetsOnSave: false,
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()) {
@@ -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: false,
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.14",
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",