@@ -84,7 +84,7 @@ interface SnapPage {
8484class 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 ( / [ x y ] / 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, GridSpan, PageLayoutId, Visible) VALUES (?, ?, ?, '1,1', NULL, 1)"
1290+ ) . run ( placementId , elementRefId , `${ prefX } ,${ prefY } ` ) ;
1291+ } else {
1292+ db . prepare (
1293+ "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, Visible) VALUES (?, ?, ?, '1,1', 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, GridSpan, PageLayoutId, Visible) VALUES (?, ?, ?, '1,1', ?, ?)"
1315+ ) . run ( placementId , elementRefId , gridPosition , info . id , visible ) ;
1316+ } else if ( hasPlacementPageLayoutId ) {
1317+ db . prepare (
1318+ "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, PageLayoutId) VALUES (?, ?, ?, '1,1', ?)"
1319+ ) . run ( placementId , elementRefId , gridPosition , info . id ) ;
1320+ } else if ( hasPlacementVisible ) {
1321+ db . prepare (
1322+ "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan, Visible) VALUES (?, ?, ?, '1,1', ?)"
1323+ ) . run ( placementId , elementRefId , gridPosition , visible ) ;
1324+ } else {
1325+ db . prepare (
1326+ "INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition, GridSpan) VALUES (?, ?, ?, '1,1')"
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