Skip to content

Commit 5547645

Browse files
committed
fix snap
Snap: implement saveModifiedTree (asset-preserving SQL replay) - Copy original .sps verbatim then replay pendingMutations as targeted UPDATE/INSERT - addButton inserts Button + ElementReference + CommandSequence + one ElementPlacement per PageLayout (Visible=1 at free cell, Visible=0 out-of-bounds otherwise) - capabilities.preservesAssetsOnSave: false → true - load path uses _loadButton to keep pendingMutations clean - Tested round-trip + addButton on Core First Scanning .sps; loads cleanly in TD Snap on dashboard and topic pages
1 parent e322687 commit 5547645

1 file changed

Lines changed: 377 additions & 1 deletion

File tree

src/processors/snapProcessor.ts

Lines changed: 377 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ interface SnapPage {
8484
class SnapProcessor extends BaseProcessor {
8585
readonly capabilities = {
8686
wordList: 'none' as const,
87-
preservesAssetsOnSave: false,
87+
preservesAssetsOnSave: true,
8888
newCellCreation: 'allowed' as const,
8989
};
9090

@@ -971,6 +971,382 @@ class SnapProcessor extends BaseProcessor {
971971
return await readBinaryFromInput(outputPath);
972972
}
973973

974+
/**
975+
* Save a modified tree while preserving the original SQLite schema and data.
976+
*
977+
* Strategy: copy the original .sps verbatim, then open the copy and replay
978+
* `page.pendingMutations` as targeted SQL UPDATE/INSERT statements. Everything
979+
* not in the mutation log (PageLayout, ScanGroup, image blobs, ContentTypeData,
980+
* ButtonPageLink, etc.) is preserved byte-for-byte from the original.
981+
*
982+
* This is the asset-preserving counterpart to `saveFromTree` (which builds a
983+
* stripped-down DB from scratch and is unsuitable for round-tripping real
984+
* TD Snap page sets).
985+
*
986+
* Supported mutations:
987+
* - updateButton(id, patch) → UPDATE Button SET Label/Message WHERE Id = ?
988+
* - removeButton(id) → UPDATE ElementPlacement SET Visible = 0 for all
989+
* placements pointing at the button's ElementReference
990+
* - addButton(button) → INSERT into ElementReference + Button + one
991+
* ElementPlacement per existing PageLayout for
992+
* the target page (so the button shows in every
993+
* layout the user has). Image/audio not yet handled.
994+
*
995+
* WordList mutations are no-ops on Snap (capabilities.wordList === 'none').
996+
*/
997+
async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void> {
998+
const { pathExists, mkDir, removePath, dirname, readBinaryFromInput, writeBinaryToPath } =
999+
this.options.fileAdapter;
1000+
if (!isNodeRuntime()) {
1001+
throw new Error('saveModifiedTree is only supported in Node.js for Snap files.');
1002+
}
1003+
1004+
const outputDir = dirname(outputPath);
1005+
if (!(await pathExists(outputDir))) {
1006+
await mkDir(outputDir, { recursive: true });
1007+
}
1008+
if (await pathExists(outputPath)) {
1009+
await removePath(outputPath);
1010+
}
1011+
1012+
// 1. Copy the original verbatim — preserves all 23+ tables, blobs, settings.
1013+
const originalBytes = await readBinaryFromInput(originalPath);
1014+
await writeBinaryToPath(outputPath, originalBytes);
1015+
1016+
// Short-circuit: if no page has any mutations, we're done.
1017+
const hasAnyMutations = Object.values(tree.pages).some(
1018+
(page) => page.pendingMutations.length > 0
1019+
);
1020+
if (!hasAnyMutations) {
1021+
return;
1022+
}
1023+
1024+
// 2. Open the copy.
1025+
const Database = requireBetterSqlite3();
1026+
const db = new Database(outputPath, { readonly: false });
1027+
1028+
try {
1029+
// 3. Schema introspection — different Snap versions have different optional columns.
1030+
const tableColumns = (table: string): Set<string> => {
1031+
try {
1032+
const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{
1033+
name: string;
1034+
}>;
1035+
return new Set(rows.map((r) => r.name));
1036+
} catch {
1037+
return new Set();
1038+
}
1039+
};
1040+
const buttonCols = tableColumns('Button');
1041+
const placementCols = tableColumns('ElementPlacement');
1042+
const hasPlacementVisible = placementCols.has('Visible');
1043+
const hasPlacementPageLayoutId = placementCols.has('PageLayoutId');
1044+
1045+
// 4. UniqueId → numeric Page.Id map.
1046+
const pageRows = db.prepare('SELECT Id, UniqueId FROM Page').all() as Array<{
1047+
Id: number;
1048+
UniqueId: string | null;
1049+
}>;
1050+
const uniqueIdToPageId = new Map<string, number>();
1051+
for (const row of pageRows) {
1052+
if (row.UniqueId) uniqueIdToPageId.set(String(row.UniqueId), row.Id);
1053+
// Allow lookup by stringified numeric Id too, since loadIntoTree falls back to it.
1054+
uniqueIdToPageId.set(String(row.Id), row.Id);
1055+
}
1056+
1057+
// Page.Id → list of PageLayout.Id, plus parsed dimensions, plus occupied cells per layout.
1058+
// PageLayoutSetting is a comma string like "4,3,False,0" → cols=4, rows=3.
1059+
interface LayoutInfo {
1060+
id: number;
1061+
cols: number;
1062+
rows: number;
1063+
occupied: Set<string>;
1064+
}
1065+
const layoutsByPage = new Map<number, LayoutInfo[]>();
1066+
try {
1067+
const layoutRows = db
1068+
.prepare('SELECT Id, PageId, PageLayoutSetting FROM PageLayout')
1069+
.all() as Array<{ Id: number; PageId: number; PageLayoutSetting: string | null }>;
1070+
1071+
// Pre-load occupied placements per layout so we can avoid (x,y) collisions.
1072+
const placementsByLayout = new Map<number, Set<string>>();
1073+
const placementRows = db
1074+
.prepare(
1075+
'SELECT PageLayoutId, GridPosition FROM ElementPlacement WHERE GridPosition IS NOT NULL AND PageLayoutId IS NOT NULL'
1076+
)
1077+
.all() as Array<{ PageLayoutId: number; GridPosition: string }>;
1078+
for (const r of placementRows) {
1079+
let set = placementsByLayout.get(r.PageLayoutId);
1080+
if (!set) {
1081+
set = new Set();
1082+
placementsByLayout.set(r.PageLayoutId, set);
1083+
}
1084+
set.add(r.GridPosition);
1085+
}
1086+
1087+
for (const row of layoutRows) {
1088+
const parts = String(row.PageLayoutSetting ?? '').split(',');
1089+
const cols = parseInt(parts[0], 10) || 4;
1090+
const rows = parseInt(parts[1], 10) || 4;
1091+
const info: LayoutInfo = {
1092+
id: row.Id,
1093+
cols,
1094+
rows,
1095+
occupied: placementsByLayout.get(row.Id) ?? new Set(),
1096+
};
1097+
const list = layoutsByPage.get(row.PageId);
1098+
if (list) list.push(info);
1099+
else layoutsByPage.set(row.PageId, [info]);
1100+
}
1101+
} catch {
1102+
// PageLayout table may not exist on older schemas — placements get NULL PageLayoutId.
1103+
}
1104+
1105+
// Find first empty cell on a layout, starting from a preferred (x,y).
1106+
// Returns null if the layout is fully occupied.
1107+
const findFreeCell = (info: LayoutInfo, prefX: number, prefY: number): string | null => {
1108+
const inBounds = (x: number, y: number): boolean =>
1109+
x >= 0 && x < info.cols && y >= 0 && y < info.rows;
1110+
if (inBounds(prefX, prefY)) {
1111+
const key = `${prefX},${prefY}`;
1112+
if (!info.occupied.has(key)) {
1113+
info.occupied.add(key);
1114+
return key;
1115+
}
1116+
}
1117+
for (let y = 0; y < info.rows; y++) {
1118+
for (let x = 0; x < info.cols; x++) {
1119+
const key = `${x},${y}`;
1120+
if (!info.occupied.has(key)) {
1121+
info.occupied.add(key);
1122+
return key;
1123+
}
1124+
}
1125+
}
1126+
return null;
1127+
};
1128+
1129+
// Generate a UUID for new Button.UniqueId. Required: Node has crypto.randomUUID since 14.17.
1130+
const nodeCrypto = getNodeRequire()?.('crypto') as typeof import('crypto') | undefined;
1131+
const uuid = (): string =>
1132+
nodeCrypto?.randomUUID
1133+
? nodeCrypto.randomUUID()
1134+
: // Fallback (very unlikely path, but shouldn't break older Nodes)
1135+
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
1136+
const r = (Math.random() * 16) | 0;
1137+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
1138+
return v.toString(16);
1139+
});
1140+
1141+
// 5. Next-available IDs for inserts.
1142+
const nextId = (table: string): number => {
1143+
const row = db.prepare(`SELECT COALESCE(MAX(Id), 0) AS maxId FROM ${table}`).get() as {
1144+
maxId: number;
1145+
};
1146+
return (row.maxId || 0) + 1;
1147+
};
1148+
let nextButtonId = nextId('Button');
1149+
let nextElementRefId = nextId('ElementReference');
1150+
let nextPlacementId = nextId('ElementPlacement');
1151+
// CommandSequence is optional but TD Snap crashes on some pages (e.g. dashboards
1152+
// like "Google Home Speaker") when a button has no CommandSequence row. Every
1153+
// button in the original DB has one. We track this only if the table exists.
1154+
const hasCommandSequence = tableColumns('CommandSequence').size > 0;
1155+
let nextCommandSequenceId = hasCommandSequence ? nextId('CommandSequence') : 0;
1156+
1157+
// 6. Replay mutations inside one transaction for atomicity + speed.
1158+
const replay = db.transaction(() => {
1159+
for (const page of Object.values(tree.pages)) {
1160+
if (page.pendingMutations.length === 0) continue;
1161+
1162+
const numericPageId = uniqueIdToPageId.get(String(page.id));
1163+
if (numericPageId === undefined) {
1164+
// eslint-disable-next-line no-console
1165+
console.warn(
1166+
`[Snap] saveModifiedTree: page "${page.name}" (${page.id}) not found in original DB; skipping ${page.pendingMutations.length} mutation(s)`
1167+
);
1168+
continue;
1169+
}
1170+
1171+
for (const mutation of page.pendingMutations) {
1172+
switch (mutation.type) {
1173+
case 'updateButton': {
1174+
const sets: string[] = [];
1175+
const args: unknown[] = [];
1176+
if (mutation.patch.label !== undefined && buttonCols.has('Label')) {
1177+
sets.push('Label = ?');
1178+
args.push(mutation.patch.label);
1179+
}
1180+
if (mutation.patch.message !== undefined && buttonCols.has('Message')) {
1181+
sets.push('Message = ?');
1182+
args.push(mutation.patch.message);
1183+
}
1184+
if (sets.length === 0) break;
1185+
args.push(Number(mutation.buttonId));
1186+
db.prepare(`UPDATE Button SET ${sets.join(', ')} WHERE Id = ?`).run(...args);
1187+
break;
1188+
}
1189+
1190+
case 'removeButton': {
1191+
// Hide all placements that reference the button's ElementReference.
1192+
const buttonRow = db
1193+
.prepare('SELECT ElementReferenceId FROM Button WHERE Id = ?')
1194+
.get(Number(mutation.buttonId)) as { ElementReferenceId: number } | undefined;
1195+
if (!buttonRow || buttonRow.ElementReferenceId == null) break;
1196+
if (hasPlacementVisible) {
1197+
db.prepare(
1198+
'UPDATE ElementPlacement SET Visible = 0 WHERE ElementReferenceId = ?'
1199+
).run(buttonRow.ElementReferenceId);
1200+
} else {
1201+
// Older schemas without Visible: delete the placements outright.
1202+
db.prepare('DELETE FROM ElementPlacement WHERE ElementReferenceId = ?').run(
1203+
buttonRow.ElementReferenceId
1204+
);
1205+
}
1206+
break;
1207+
}
1208+
1209+
case 'addButton': {
1210+
const button = mutation.button;
1211+
const elementRefId = nextElementRefId++;
1212+
const buttonId = nextButtonId++;
1213+
1214+
// ElementReference: TD Snap requires ElementType, ForegroundColor,
1215+
// BackgroundColor, and AudioCueRecordingId to be non-NULL on render
1216+
// — NULL values crash dashboard pages (e.g. "Google Home Speaker").
1217+
// Defaults below match the modal values across real Snap files
1218+
// (>99% of existing rows in Core First Scanning use them).
1219+
const erColumns = tableColumns('ElementReference');
1220+
const erCandidates: Array<{ col: string; value: unknown }> = [
1221+
{ col: 'Id', value: elementRefId },
1222+
{ col: 'PageId', value: numericPageId },
1223+
{ col: 'ElementType', value: 0 }, // 0 = button; only nonzero in 1/20608 rows
1224+
{ col: 'ForegroundColor', value: -14934754 }, // dark text default (99.8% of rows)
1225+
{ col: 'BackgroundColor', value: -132102 }, // light cell default (85.7% of rows)
1226+
{ col: 'AudioCueRecordingId', value: 0 }, // 0 = no audio cue (99.99% of rows)
1227+
];
1228+
const erFieldsPresent = erCandidates.filter((f) => erColumns.has(f.col));
1229+
db.prepare(
1230+
`INSERT INTO ElementReference (${erFieldsPresent
1231+
.map((f) => f.col)
1232+
.join(', ')}) VALUES (${erFieldsPresent.map(() => '?').join(', ')})`
1233+
).run(...erFieldsPresent.map((f) => f.value));
1234+
1235+
// Button: provide non-NULL defaults for columns TD Snap reads.
1236+
// ContentType = 6 (normal speak button), CommandFlags = 8 (standard),
1237+
// LabelOwnership / ImageOwnership = 3 (owned by this page set),
1238+
// ActiveContentType = 0, BorderThickness = 0, UniqueId = fresh GUID,
1239+
// image / sound / symbol IDs = 0 (= "no asset").
1240+
const candidateFields: Array<{ col: string; value: unknown }> = [
1241+
{ col: 'Id', value: buttonId },
1242+
{ col: 'Label', value: button.label || '' },
1243+
{ col: 'Message', value: button.message || button.label || '' },
1244+
{ col: 'ElementReferenceId', value: elementRefId },
1245+
{ col: 'ContentType', value: 6 },
1246+
{ col: 'CommandFlags', value: 8 },
1247+
{ col: 'LabelOwnership', value: 3 },
1248+
{ col: 'ImageOwnership', value: 3 },
1249+
{ col: 'ActiveContentType', value: 0 },
1250+
{ col: 'BorderThickness', value: 0 },
1251+
{ col: 'UniqueId', value: uuid() },
1252+
{ col: 'LibrarySymbolId', value: 0 },
1253+
{ col: 'PageSetImageId', value: 0 },
1254+
{ col: 'MessageRecordingId', value: 0 },
1255+
// UseMessageRecording: omit. 99.99% of existing rows have NULL,
1256+
// and forcing 0 makes Sarah/Mum the only outliers in the DB.
1257+
{ col: 'SymbolColorDataId', value: 0 },
1258+
];
1259+
const presentFields = candidateFields.filter((f) => buttonCols.has(f.col));
1260+
const sql = `INSERT INTO Button (${presentFields
1261+
.map((f) => f.col)
1262+
.join(', ')}) VALUES (${presentFields.map(() => '?').join(', ')})`;
1263+
db.prepare(sql).run(...presentFields.map((f) => f.value));
1264+
1265+
// Insert a default CommandSequence row that explicitly speaks the
1266+
// button's Label ($type:3 = MessageAction, value 0 = speak Label).
1267+
// This is the most common pattern in real Snap files (9514 / 19402
1268+
// buttons in Core First Scanning). Without an explicit action,
1269+
// dashboard pages (e.g. "Google Home Speaker") crash on render —
1270+
// empty $values is only used for hidden/help-text buttons.
1271+
if (hasCommandSequence) {
1272+
db.prepare(
1273+
'INSERT INTO CommandSequence (Id, SerializedCommands, ButtonId) VALUES (?, ?, ?)'
1274+
).run(
1275+
nextCommandSequenceId++,
1276+
'{"$type":"1","$values":[{"$type":"3","MessageAction":0}]}',
1277+
buttonId
1278+
);
1279+
}
1280+
1281+
const layouts = layoutsByPage.get(numericPageId) ?? [];
1282+
const prefX = button.x ?? 0;
1283+
const prefY = button.y ?? 0;
1284+
1285+
if (layouts.length === 0) {
1286+
const placementId = nextPlacementId++;
1287+
if (hasPlacementPageLayoutId) {
1288+
db.prepare(
1289+
'INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, PageLayoutId, Visible) VALUES (?, ?, ?, NULL, 1)'
1290+
).run(placementId, elementRefId, `${prefX},${prefY}`);
1291+
} else {
1292+
db.prepare(
1293+
'INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, Visible) VALUES (?, ?, ?, 1)'
1294+
).run(placementId, elementRefId, `${prefX},${prefY}`);
1295+
}
1296+
} else {
1297+
// INVARIANT: every Button must have exactly one ElementPlacement
1298+
// per PageLayout on its page. Snap renders buttons × layouts and
1299+
// crashes if a (button, layout) pair is missing.
1300+
//
1301+
// Hidden placements (Visible=0) MUST use a position that doesn't
1302+
// collide with an existing visible placement on that layout.
1303+
// The existing convention puts hidden placements at out-of-grid
1304+
// coordinates (e.g. position (2,4) on a 4×3 layout where row 4
1305+
// doesn't exist). We do the same: synthesise (cols, 0) which is
1306+
// guaranteed out of bounds since valid X is 0..cols-1.
1307+
for (const info of layouts) {
1308+
const cell = findFreeCell(info, prefX, prefY);
1309+
const visible = cell !== null ? 1 : 0;
1310+
const gridPosition = cell ?? `${info.cols},0`;
1311+
const placementId = nextPlacementId++;
1312+
if (hasPlacementPageLayoutId && hasPlacementVisible) {
1313+
db.prepare(
1314+
'INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, PageLayoutId, Visible) VALUES (?, ?, ?, ?, ?)'
1315+
).run(placementId, elementRefId, gridPosition, info.id, visible);
1316+
} else if (hasPlacementPageLayoutId) {
1317+
db.prepare(
1318+
'INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, PageLayoutId) VALUES (?, ?, ?, ?)'
1319+
).run(placementId, elementRefId, gridPosition, info.id);
1320+
} else if (hasPlacementVisible) {
1321+
db.prepare(
1322+
'INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, Visible) VALUES (?, ?, ?, ?)'
1323+
).run(placementId, elementRefId, gridPosition, visible);
1324+
} else {
1325+
db.prepare(
1326+
'INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition) VALUES (?, ?, ?)'
1327+
).run(placementId, elementRefId, gridPosition);
1328+
}
1329+
}
1330+
}
1331+
break;
1332+
}
1333+
1334+
case 'addWordListItem':
1335+
case 'removeWordListItem':
1336+
case 'clearWordList':
1337+
// Snap has no WordList concept — these are no-ops by capability contract.
1338+
break;
1339+
}
1340+
}
1341+
}
1342+
});
1343+
1344+
replay();
1345+
} finally {
1346+
db.close();
1347+
}
1348+
}
1349+
9741350
async saveFromTree(tree: AACTree, outputPath: string): Promise<void> {
9751351
const { pathExists, mkDir, removePath, dirname } = this.options.fileAdapter;
9761352
if (!isNodeRuntime()) {

0 commit comments

Comments
 (0)