@willwade/aac-processors 0.1.5 → 0.1.7

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.
Files changed (55) hide show
  1. package/README.md +14 -0
  2. package/dist/browser/index.browser.js +15 -1
  3. package/dist/browser/processors/gridset/password.js +11 -0
  4. package/dist/browser/processors/gridsetProcessor.js +42 -46
  5. package/dist/browser/processors/obfProcessor.js +47 -63
  6. package/dist/browser/processors/snapProcessor.js +1031 -0
  7. package/dist/browser/processors/touchchatProcessor.js +1004 -0
  8. package/dist/browser/utils/io.js +36 -2
  9. package/dist/browser/utils/sqlite.js +109 -0
  10. package/dist/browser/utils/zip.js +54 -0
  11. package/dist/browser/validation/gridsetValidator.js +7 -27
  12. package/dist/browser/validation/obfValidator.js +9 -4
  13. package/dist/browser/validation/snapValidator.js +197 -0
  14. package/dist/browser/validation/touchChatValidator.js +201 -0
  15. package/dist/index.browser.d.ts +7 -0
  16. package/dist/index.browser.js +19 -2
  17. package/dist/processors/gridset/helpers.js +3 -4
  18. package/dist/processors/gridset/index.d.ts +1 -1
  19. package/dist/processors/gridset/index.js +3 -2
  20. package/dist/processors/gridset/password.d.ts +3 -2
  21. package/dist/processors/gridset/password.js +12 -0
  22. package/dist/processors/gridset/wordlistHelpers.js +107 -51
  23. package/dist/processors/gridsetProcessor.js +40 -44
  24. package/dist/processors/obfProcessor.js +46 -62
  25. package/dist/processors/snapProcessor.js +60 -54
  26. package/dist/processors/touchchatProcessor.js +38 -36
  27. package/dist/utils/io.d.ts +4 -0
  28. package/dist/utils/io.js +40 -2
  29. package/dist/utils/sqlite.d.ts +21 -0
  30. package/dist/utils/sqlite.js +137 -0
  31. package/dist/utils/zip.d.ts +7 -0
  32. package/dist/utils/zip.js +80 -0
  33. package/dist/validation/applePanelsValidator.js +11 -28
  34. package/dist/validation/astericsValidator.js +11 -30
  35. package/dist/validation/dotValidator.js +11 -30
  36. package/dist/validation/excelValidator.js +5 -6
  37. package/dist/validation/gridsetValidator.js +29 -26
  38. package/dist/validation/index.d.ts +2 -1
  39. package/dist/validation/index.js +9 -32
  40. package/dist/validation/obfValidator.js +8 -3
  41. package/dist/validation/obfsetValidator.js +11 -30
  42. package/dist/validation/opmlValidator.js +11 -30
  43. package/dist/validation/snapValidator.js +6 -9
  44. package/dist/validation/touchChatValidator.js +6 -7
  45. package/docs/BROWSER_USAGE.md +2 -10
  46. package/examples/README.md +3 -75
  47. package/examples/vitedemo/README.md +13 -7
  48. package/examples/vitedemo/index.html +51 -2
  49. package/examples/vitedemo/package-lock.json +9 -0
  50. package/examples/vitedemo/package.json +1 -0
  51. package/examples/vitedemo/src/main.ts +132 -2
  52. package/examples/vitedemo/src/vite-env.d.ts +1 -0
  53. package/examples/vitedemo/vite.config.ts +26 -7
  54. package/package.json +3 -1
  55. package/examples/browser-test-server.js +0 -81
@@ -0,0 +1,1031 @@
1
+ import { BaseProcessor, } from '../core/baseProcessor';
2
+ import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, } from '../core/treeStructure';
3
+ import { generateCloneId } from '../utilities/analytics/utils/idGenerator';
4
+ import { SnapValidator } from '../validation/snapValidator';
5
+ import { getFs, getNodeRequire, getPath, isNodeRuntime } from '../utils/io';
6
+ import { openSqliteDatabase, requireBetterSqlite3 } from '../utils/sqlite';
7
+ /**
8
+ * Map Snap Visible value to AAC standard visibility
9
+ * Snap: 0 = hidden, 1 (or non-zero) = visible
10
+ * Maps to: 'Hidden' | 'Visible' | undefined
11
+ */
12
+ function mapSnapVisibility(visible) {
13
+ if (visible === null || visible === undefined) {
14
+ return undefined; // Default to visible
15
+ }
16
+ return visible === 0 ? 'Hidden' : 'Visible';
17
+ }
18
+ class SnapProcessor extends BaseProcessor {
19
+ constructor(symbolResolver = null, options = {}) {
20
+ super(options);
21
+ this.symbolResolver = null;
22
+ this.loadAudio = false;
23
+ this.pageLayoutPreference = 'scanning'; // Default to scanning for metrics
24
+ this.symbolResolver = symbolResolver;
25
+ this.loadAudio = options.loadAudio !== undefined ? options.loadAudio : true;
26
+ this.pageLayoutPreference =
27
+ options.pageLayoutPreference !== undefined ? options.pageLayoutPreference : 'scanning'; // Default to scanning
28
+ }
29
+ async extractTexts(filePathOrBuffer) {
30
+ const tree = await this.loadIntoTree(filePathOrBuffer);
31
+ const texts = [];
32
+ for (const pageId in tree.pages) {
33
+ const page = tree.pages[pageId];
34
+ // Include page names
35
+ if (page.name)
36
+ texts.push(page.name);
37
+ // Include button texts
38
+ page.buttons.forEach((btn) => {
39
+ if (btn.label)
40
+ texts.push(btn.label);
41
+ if (btn.message && btn.message !== btn.label)
42
+ texts.push(btn.message);
43
+ });
44
+ }
45
+ return texts;
46
+ }
47
+ async loadIntoTree(filePathOrBuffer) {
48
+ await Promise.resolve();
49
+ const tree = new AACTree();
50
+ let dbResult = null;
51
+ try {
52
+ dbResult = await openSqliteDatabase(filePathOrBuffer, { readonly: true });
53
+ const db = dbResult.db;
54
+ const getTableColumns = (tableName) => {
55
+ try {
56
+ const rows = db.prepare(`PRAGMA table_info(${tableName})`).all();
57
+ return new Set(rows.map((row) => row.name));
58
+ }
59
+ catch {
60
+ return new Set();
61
+ }
62
+ };
63
+ // Load pages first, using UniqueId as canonical id
64
+ const pages = db.prepare('SELECT * FROM Page').all();
65
+ // Load PageSetProperties to find default Keyboard and Home pages
66
+ let defaultKeyboardPageId;
67
+ let defaultHomePageId;
68
+ let dashboardPageId;
69
+ let toolbarId;
70
+ try {
71
+ const properties = db.prepare('SELECT * FROM PageSetProperties').get();
72
+ if (properties) {
73
+ defaultKeyboardPageId = properties.DefaultKeyboardPageUniqueId;
74
+ defaultHomePageId = properties.DefaultHomePageUniqueId;
75
+ dashboardPageId = properties.DashboardUniqueId;
76
+ toolbarId = properties.ToolBarUniqueId;
77
+ const hasGlobalToolbar = toolbarId && toolbarId !== '00000000-0000-0000-0000-000000000000';
78
+ // Store metadata in tree
79
+ const metadata = {
80
+ format: 'snap',
81
+ name: properties.Name || properties.PageSetName || undefined,
82
+ description: properties.Description || undefined,
83
+ author: properties.Author || undefined,
84
+ locale: properties.Locale || undefined,
85
+ languages: properties.Locale ? [properties.Locale] : undefined,
86
+ defaultKeyboardPageId: defaultKeyboardPageId || undefined,
87
+ defaultHomePageId: defaultHomePageId || undefined,
88
+ dashboardId: dashboardPageId || undefined,
89
+ hasGlobalToolbar: !!hasGlobalToolbar,
90
+ };
91
+ tree.metadata = metadata;
92
+ // Set toolbarId if there's a global toolbar
93
+ if (hasGlobalToolbar) {
94
+ tree.toolbarId = toolbarId || null;
95
+ // Use defaultHomePageId as root (the content pageset), not the toolbar
96
+ tree.rootId = defaultHomePageId || null;
97
+ }
98
+ else if (defaultHomePageId) {
99
+ tree.rootId = defaultHomePageId;
100
+ }
101
+ }
102
+ }
103
+ catch (e) {
104
+ console.warn('[SnapProcessor] Failed to load PageSetProperties:', e);
105
+ }
106
+ // If still no root, fallback to first page (but don't override a valid defaultHomePageId)
107
+ if (!tree.rootId && pages.length > 0) {
108
+ tree.rootId = String(pages[0].UniqueId || pages[0].Id);
109
+ }
110
+ // Map from numeric Id -> UniqueId for later lookup
111
+ const idToUniqueId = {};
112
+ pages.forEach((pageRow) => {
113
+ const uniqueId = String(pageRow.UniqueId || pageRow.Id);
114
+ idToUniqueId[String(pageRow.Id)] = uniqueId;
115
+ const page = new AACPage({
116
+ id: uniqueId,
117
+ name: pageRow.Title || pageRow.Name,
118
+ grid: [],
119
+ buttons: [],
120
+ parentId: null, // ParentId will be set via navigation buttons below
121
+ style: {
122
+ backgroundColor: pageRow.BackgroundColor
123
+ ? `#${pageRow.BackgroundColor.toString(16)}`
124
+ : undefined,
125
+ },
126
+ });
127
+ tree.addPage(page);
128
+ });
129
+ // Try to find toolbar page even if not set in PageSetProperties
130
+ // Some SNAP files have a toolbar page but don't set ToolBarUniqueId
131
+ // This must be done AFTER pages are added to the tree
132
+ if (!tree.toolbarId || tree.toolbarId === '00000000-0000-0000-0000-000000000000') {
133
+ const toolbarPage = Object.values(tree.pages).find((p) => {
134
+ const name = (p.name || '').toLowerCase();
135
+ return name === 'tool bar' || name === 'toolbar';
136
+ });
137
+ if (toolbarPage) {
138
+ tree.toolbarId = toolbarPage.id;
139
+ // Update metadata to reflect toolbar detection
140
+ if (tree.metadata) {
141
+ tree.metadata.hasGlobalToolbar = true;
142
+ }
143
+ }
144
+ }
145
+ const scanGroupsByPageLayout = new Map();
146
+ try {
147
+ const scanGroupRows = db
148
+ .prepare('SELECT Id, SerializedGridPositions, PageLayoutId FROM ScanGroup ORDER BY Id')
149
+ .all();
150
+ if (scanGroupRows && scanGroupRows.length > 0) {
151
+ // Group by PageLayoutId first
152
+ const groupsByLayout = new Map();
153
+ scanGroupRows.forEach((sg) => {
154
+ if (!groupsByLayout.has(sg.PageLayoutId)) {
155
+ groupsByLayout.set(sg.PageLayoutId, []);
156
+ }
157
+ const layoutGroups = groupsByLayout.get(sg.PageLayoutId);
158
+ if (layoutGroups) {
159
+ layoutGroups.push(sg);
160
+ }
161
+ });
162
+ // For each PageLayout, assign scan block numbers based on order (1-based index)
163
+ groupsByLayout.forEach((groups, layoutId) => {
164
+ groups.forEach((sg, index) => {
165
+ // Parse SerializedGridPositions JSON
166
+ let positions = [];
167
+ try {
168
+ positions = JSON.parse(sg.SerializedGridPositions);
169
+ }
170
+ catch (e) {
171
+ // Invalid JSON, skip this group
172
+ return;
173
+ }
174
+ const scanGroup = {
175
+ id: sg.Id,
176
+ scanBlock: index + 1, // Scan block is 1-based index
177
+ positions: positions,
178
+ };
179
+ if (!scanGroupsByPageLayout.has(layoutId)) {
180
+ scanGroupsByPageLayout.set(layoutId, []);
181
+ }
182
+ const layoutGroups = scanGroupsByPageLayout.get(layoutId);
183
+ if (layoutGroups) {
184
+ layoutGroups.push(scanGroup);
185
+ }
186
+ });
187
+ });
188
+ }
189
+ }
190
+ catch (e) {
191
+ // No ScanGroups table or error loading, continue without scan blocks
192
+ console.warn('[SnapProcessor] Failed to load ScanGroups:', e);
193
+ }
194
+ // Load buttons per page, using UniqueId for page id
195
+ for (const pageRow of pages) {
196
+ // Create a map to track page grid layouts
197
+ const pageGrids = new Map();
198
+ // Select PageLayout for this page based on preference
199
+ let selectedPageLayoutId = null;
200
+ try {
201
+ const pageLayouts = db
202
+ .prepare('SELECT Id, PageLayoutSetting FROM PageLayout WHERE PageId = ?')
203
+ .all(pageRow.Id);
204
+ if (pageLayouts && pageLayouts.length > 0) {
205
+ // Parse PageLayoutSetting: "columns,rows,hasScanGroups,?"
206
+ const layoutsWithInfo = pageLayouts.map((pl) => {
207
+ const parts = pl.PageLayoutSetting.split(',');
208
+ const cols = parseInt(parts[0], 10) || 0;
209
+ const rows = parseInt(parts[1], 10) || 0;
210
+ const hasScanning = parts[2] === 'True';
211
+ const size = cols * rows;
212
+ return { id: pl.Id, cols, rows, size, hasScanning };
213
+ });
214
+ // Select based on preference
215
+ if (typeof this.pageLayoutPreference === 'number') {
216
+ // Specific PageLayoutId
217
+ selectedPageLayoutId = this.pageLayoutPreference;
218
+ }
219
+ else if (this.pageLayoutPreference === 'largest') {
220
+ // Select layout with largest grid size, prefer layouts with ScanGroups
221
+ layoutsWithInfo.sort((a, b) => {
222
+ const sizeDiff = b.size - a.size;
223
+ if (sizeDiff !== 0)
224
+ return sizeDiff;
225
+ // Same size, prefer one with ScanGroups
226
+ const aHasScanning = scanGroupsByPageLayout.has(a.id);
227
+ const bHasScanning = scanGroupsByPageLayout.has(b.id);
228
+ return (bHasScanning ? 1 : 0) - (aHasScanning ? 1 : 0);
229
+ });
230
+ selectedPageLayoutId = layoutsWithInfo[0].id;
231
+ }
232
+ else if (this.pageLayoutPreference === 'smallest') {
233
+ // Select layout with smallest grid size, prefer layouts with ScanGroups
234
+ layoutsWithInfo.sort((a, b) => {
235
+ const sizeDiff = a.size - b.size;
236
+ if (sizeDiff !== 0)
237
+ return sizeDiff;
238
+ // Same size, prefer one with ScanGroups
239
+ const aHasScanning = scanGroupsByPageLayout.has(a.id);
240
+ const bHasScanning = scanGroupsByPageLayout.has(b.id);
241
+ return (bHasScanning ? 1 : 0) - (aHasScanning ? 1 : 0);
242
+ });
243
+ selectedPageLayoutId = layoutsWithInfo[0].id;
244
+ }
245
+ else if (this.pageLayoutPreference === 'scanning') {
246
+ // Select layout with scanning enabled (check against actual ScanGroups)
247
+ const scanningLayouts = layoutsWithInfo.filter((l) => scanGroupsByPageLayout.has(l.id));
248
+ if (scanningLayouts.length > 0) {
249
+ scanningLayouts.sort((a, b) => b.size - a.size);
250
+ selectedPageLayoutId = scanningLayouts[0].id;
251
+ }
252
+ else {
253
+ // Fallback to largest
254
+ layoutsWithInfo.sort((a, b) => b.size - a.size);
255
+ selectedPageLayoutId = layoutsWithInfo[0].id;
256
+ }
257
+ }
258
+ }
259
+ }
260
+ catch (e) {
261
+ // Error selecting PageLayout, will load all buttons
262
+ console.warn(`[SnapProcessor] Failed to select PageLayout for page ${pageRow.Id}:`, e);
263
+ }
264
+ // Load buttons
265
+ let buttons = [];
266
+ try {
267
+ const buttonColumns = getTableColumns('Button');
268
+ const selectFields = [
269
+ 'b.Id',
270
+ 'b.Label',
271
+ 'b.Message',
272
+ buttonColumns.has('LibrarySymbolId') ? 'b.LibrarySymbolId' : 'NULL AS LibrarySymbolId',
273
+ buttonColumns.has('PageSetImageId') ? 'b.PageSetImageId' : 'NULL AS PageSetImageId',
274
+ buttonColumns.has('BorderColor') ? 'b.BorderColor' : 'NULL AS BorderColor',
275
+ buttonColumns.has('BorderThickness') ? 'b.BorderThickness' : 'NULL AS BorderThickness',
276
+ buttonColumns.has('FontSize') ? 'b.FontSize' : 'NULL AS FontSize',
277
+ buttonColumns.has('FontFamily') ? 'b.FontFamily' : 'NULL AS FontFamily',
278
+ buttonColumns.has('FontStyle') ? 'b.FontStyle' : 'NULL AS FontStyle',
279
+ buttonColumns.has('LabelColor') ? 'b.LabelColor' : 'NULL AS LabelColor',
280
+ buttonColumns.has('BackgroundColor') ? 'b.BackgroundColor' : 'NULL AS BackgroundColor',
281
+ buttonColumns.has('NavigatePageId') ? 'b.NavigatePageId' : 'NULL AS NavigatePageId',
282
+ buttonColumns.has('ContentType') ? 'b.ContentType' : 'NULL AS ContentType',
283
+ ];
284
+ if (this.loadAudio) {
285
+ selectFields.push(buttonColumns.has('MessageRecordingId')
286
+ ? 'b.MessageRecordingId'
287
+ : 'NULL AS MessageRecordingId');
288
+ selectFields.push(buttonColumns.has('UseMessageRecording')
289
+ ? 'b.UseMessageRecording'
290
+ : 'NULL AS UseMessageRecording');
291
+ selectFields.push(buttonColumns.has('SerializedMessageSoundMetadata')
292
+ ? 'b.SerializedMessageSoundMetadata'
293
+ : 'NULL AS SerializedMessageSoundMetadata');
294
+ }
295
+ const placementColumns = getTableColumns('ElementPlacement');
296
+ const hasButtonPageLink = getTableColumns('ButtonPageLink').size > 0;
297
+ selectFields.push(placementColumns.has('GridPosition') ? 'ep.GridPosition' : 'NULL AS GridPosition', placementColumns.has('PageLayoutId') ? 'ep.PageLayoutId' : 'NULL AS PageLayoutId', placementColumns.has('Visible') ? 'ep.Visible' : 'NULL AS Visible', 'er.PageId as ButtonPageId');
298
+ if (hasButtonPageLink) {
299
+ selectFields.push('bpl.PageUniqueId AS LinkedPageUniqueId');
300
+ }
301
+ else {
302
+ selectFields.push('NULL AS LinkedPageUniqueId');
303
+ }
304
+ const hasCommandSequence = getTableColumns('CommandSequence').size > 0;
305
+ if (hasCommandSequence) {
306
+ selectFields.push('cs.SerializedCommands');
307
+ }
308
+ else {
309
+ selectFields.push('NULL AS SerializedCommands');
310
+ }
311
+ const buttonQuery = `
312
+ SELECT ${selectFields.join(', ')}
313
+ FROM Button b
314
+ INNER JOIN ElementReference er ON b.ElementReferenceId = er.Id
315
+ LEFT JOIN ElementPlacement ep ON ep.ElementReferenceId = er.Id
316
+ ${hasButtonPageLink ? 'LEFT JOIN ButtonPageLink bpl ON b.Id = bpl.ButtonId' : ''}
317
+ ${hasCommandSequence ? 'LEFT JOIN CommandSequence cs ON b.Id = cs.ButtonId' : ''}
318
+ WHERE er.PageId = ? ${selectedPageLayoutId ? 'AND ep.PageLayoutId = ?' : ''}
319
+ `;
320
+ if (selectedPageLayoutId) {
321
+ buttons = db.prepare(buttonQuery).all(pageRow.Id, selectedPageLayoutId);
322
+ }
323
+ else {
324
+ buttons = db.prepare(buttonQuery).all(pageRow.Id);
325
+ }
326
+ }
327
+ catch (err) {
328
+ const errorMessage = err instanceof Error ? err.message : String(err);
329
+ const errorCode = err && typeof err === 'object' && 'code' in err ? err.code : undefined;
330
+ if (errorCode === 'SQLITE_CORRUPT' ||
331
+ errorCode === 'SQLITE_NOTADB' ||
332
+ /malformed/i.test(errorMessage)) {
333
+ throw new Error(`Snap database is corrupted or incomplete: ${errorMessage}`);
334
+ }
335
+ console.warn(`Failed to load buttons for page ${pageRow.Id}: ${errorMessage}`);
336
+ // Skip this page instead of loading all buttons
337
+ buttons = [];
338
+ }
339
+ const uniqueId = String(pageRow.UniqueId || pageRow.Id);
340
+ const page = tree.getPage(uniqueId);
341
+ if (!page) {
342
+ continue;
343
+ }
344
+ // Initialize page grid if not exists (assume max 10x10 grid)
345
+ if (!pageGrids.has(uniqueId)) {
346
+ const grid = [];
347
+ for (let r = 0; r < 10; r++) {
348
+ grid[r] = new Array(10).fill(null);
349
+ }
350
+ pageGrids.set(uniqueId, grid);
351
+ }
352
+ const pageGrid = pageGrids.get(uniqueId);
353
+ if (!pageGrid)
354
+ continue;
355
+ buttons.forEach((btnRow) => {
356
+ // Determine navigation target UniqueId, if possible
357
+ let targetPageUniqueId = undefined;
358
+ if (btnRow.NavigatePageId && idToUniqueId[String(btnRow.NavigatePageId)]) {
359
+ targetPageUniqueId = idToUniqueId[String(btnRow.NavigatePageId)];
360
+ }
361
+ else if (btnRow.LinkedPageUniqueId) {
362
+ targetPageUniqueId = String(btnRow.LinkedPageUniqueId);
363
+ }
364
+ else if (btnRow.PageUniqueId) {
365
+ targetPageUniqueId = String(btnRow.PageUniqueId);
366
+ }
367
+ // Parse CommandSequence for navigation targets if not found yet
368
+ if (btnRow.SerializedCommands) {
369
+ try {
370
+ const commands = JSON.parse(btnRow.SerializedCommands);
371
+ const values = commands.$values || [];
372
+ for (const cmd of values) {
373
+ if (cmd.$type === '2' && cmd.LinkedPageId) {
374
+ // Normal Navigation
375
+ targetPageUniqueId = String(cmd.LinkedPageId);
376
+ }
377
+ else if (cmd.$type === '16') {
378
+ // Go to Home
379
+ targetPageUniqueId = defaultHomePageId;
380
+ }
381
+ else if (cmd.$type === '17') {
382
+ // Go to Keyboard
383
+ targetPageUniqueId = defaultKeyboardPageId;
384
+ }
385
+ else if (cmd.$type === '18') {
386
+ // Go to Dashboard
387
+ targetPageUniqueId = dashboardPageId;
388
+ }
389
+ }
390
+ }
391
+ catch (e) {
392
+ // Ignore JSON parse errors in commands
393
+ }
394
+ }
395
+ // Determine parent page association for this button
396
+ const parentPageId = btnRow.ButtonPageId ? String(btnRow.ButtonPageId) : undefined;
397
+ const parentUniqueId = parentPageId && idToUniqueId[parentPageId] ? idToUniqueId[parentPageId] : uniqueId;
398
+ // Load audio recording if requested and available
399
+ let audioRecording;
400
+ if (this.loadAudio && btnRow.MessageRecordingId && btnRow.MessageRecordingId > 0) {
401
+ try {
402
+ const recordingData = db
403
+ .prepare(`
404
+ SELECT Id, Identifier, Data FROM PageSetData WHERE Id = ?
405
+ `)
406
+ .get(btnRow.MessageRecordingId);
407
+ if (recordingData) {
408
+ audioRecording = {
409
+ id: recordingData.Id,
410
+ data: recordingData.Data,
411
+ identifier: recordingData.Identifier,
412
+ metadata: btnRow.SerializedMessageSoundMetadata || undefined,
413
+ };
414
+ }
415
+ }
416
+ catch (e) {
417
+ console.warn(`[SnapProcessor] Failed to load audio for button ${btnRow.Id}:`, e);
418
+ }
419
+ }
420
+ // Create semantic action for Snap button
421
+ let semanticAction;
422
+ if (targetPageUniqueId) {
423
+ semanticAction = {
424
+ category: AACSemanticCategory.NAVIGATION,
425
+ intent: AACSemanticIntent.NAVIGATE_TO,
426
+ targetId: targetPageUniqueId,
427
+ platformData: {
428
+ snap: {
429
+ navigatePageId: btnRow.NavigatePageId,
430
+ elementReferenceId: btnRow.Id,
431
+ },
432
+ },
433
+ fallback: {
434
+ type: 'NAVIGATE',
435
+ targetPageId: targetPageUniqueId,
436
+ },
437
+ };
438
+ }
439
+ else {
440
+ semanticAction = {
441
+ category: AACSemanticCategory.COMMUNICATION,
442
+ intent: AACSemanticIntent.SPEAK_TEXT,
443
+ text: btnRow.Message || btnRow.Label || '',
444
+ platformData: {
445
+ snap: {
446
+ elementReferenceId: btnRow.Id,
447
+ },
448
+ },
449
+ fallback: {
450
+ type: 'SPEAK',
451
+ message: btnRow.Message || btnRow.Label || '',
452
+ },
453
+ };
454
+ }
455
+ const button = new AACButton({
456
+ id: String(btnRow.Id),
457
+ label: btnRow.Label || (btnRow.ContentType === 1 ? '[Prediction]' : ''),
458
+ message: btnRow.Message || (btnRow.ContentType === 1 ? '[Prediction]' : btnRow.Label || ''),
459
+ targetPageId: targetPageUniqueId,
460
+ semanticAction: semanticAction,
461
+ contentType: btnRow.ContentType === 1 ? 'AutoContent' : undefined,
462
+ contentSubType: btnRow.ContentType === 1 ? 'Prediction' : undefined,
463
+ audioRecording: audioRecording,
464
+ visibility: mapSnapVisibility(btnRow.Visible),
465
+ semantic_id: btnRow.LibrarySymbolId
466
+ ? `snap_symbol_${btnRow.LibrarySymbolId}`
467
+ : undefined, // Extract semantic_id from LibrarySymbolId
468
+ style: {
469
+ backgroundColor: btnRow.BackgroundColor
470
+ ? `#${btnRow.BackgroundColor.toString(16)}`
471
+ : undefined,
472
+ borderColor: btnRow.BorderColor ? `#${btnRow.BorderColor.toString(16)}` : undefined,
473
+ borderWidth: btnRow.BorderThickness,
474
+ fontColor: btnRow.LabelColor ? `#${btnRow.LabelColor.toString(16)}` : undefined,
475
+ fontSize: btnRow.FontSize,
476
+ fontFamily: btnRow.FontFamily,
477
+ fontStyle: btnRow.FontStyle?.toString(),
478
+ },
479
+ });
480
+ // Add to the intended parent page
481
+ const parentPage = tree.getPage(parentUniqueId);
482
+ if (parentPage) {
483
+ parentPage.addButton(button);
484
+ // Add button to grid layout if position data is available
485
+ const gridPositionStr = String(btnRow.GridPosition || '');
486
+ if (gridPositionStr && gridPositionStr.includes(',')) {
487
+ // Parse comma-separated coordinates "x,y"
488
+ const [xStr, yStr] = gridPositionStr.split(',');
489
+ const gridX = parseInt(xStr, 10);
490
+ const gridY = parseInt(yStr, 10);
491
+ // Set button x,y properties (critical for metrics!)
492
+ if (!isNaN(gridX) && !isNaN(gridY)) {
493
+ button.x = gridX;
494
+ button.y = gridY;
495
+ // Determine scan block from ScanGroups (TD Snap "Group Scan")
496
+ // IMPORTANT: Only match against ScanGroups from the SAME PageLayout
497
+ // A button can exist in multiple layouts with different positions
498
+ const buttonPageLayoutId = btnRow.PageLayoutId;
499
+ if (buttonPageLayoutId && scanGroupsByPageLayout.has(buttonPageLayoutId)) {
500
+ const scanGroups = scanGroupsByPageLayout.get(buttonPageLayoutId);
501
+ if (scanGroups && scanGroups.length > 0) {
502
+ // Find which ScanGroup contains this button's position
503
+ for (const scanGroup of scanGroups) {
504
+ // Skip if positions array is null or undefined
505
+ if (!scanGroup.positions || !Array.isArray(scanGroup.positions)) {
506
+ continue;
507
+ }
508
+ const foundInGroup = scanGroup.positions.some((pos) => pos.Column === gridX && pos.Row === gridY);
509
+ if (foundInGroup) {
510
+ // Use the scan block number from the ScanGroup
511
+ // ScanGroup scanBlock is already 1-based (index + 1)
512
+ button.scanBlock = scanGroup.scanBlock;
513
+ break; // Found the scan block, stop looking
514
+ }
515
+ }
516
+ }
517
+ }
518
+ }
519
+ // Place button in grid if within bounds and coordinates are valid
520
+ if (!isNaN(gridX) &&
521
+ !isNaN(gridY) &&
522
+ gridX >= 0 &&
523
+ gridY >= 0 &&
524
+ gridY < 10 &&
525
+ gridX < 10 &&
526
+ pageGrid[gridY] &&
527
+ pageGrid[gridY][gridX] === null) {
528
+ // Generate clone_id for button at this position
529
+ const rows = pageGrid.length;
530
+ const cols = pageGrid[0] ? pageGrid[0].length : 10;
531
+ button.clone_id = generateCloneId(rows, cols, gridY, gridX, button.label);
532
+ pageGrid[gridY][gridX] = button;
533
+ }
534
+ }
535
+ }
536
+ // If this is a navigation button, update the target page's parentId
537
+ if (targetPageUniqueId) {
538
+ const targetPage = tree.getPage(targetPageUniqueId);
539
+ if (targetPage) {
540
+ targetPage.parentId = parentUniqueId;
541
+ }
542
+ }
543
+ });
544
+ // Set grid layout for the current page
545
+ const currentPage = tree.getPage(uniqueId);
546
+ if (currentPage && pageGrid) {
547
+ currentPage.grid = pageGrid;
548
+ // Track semantic_ids and clone_ids on the page
549
+ const semanticIds = [];
550
+ const cloneIds = [];
551
+ pageGrid.forEach((row) => {
552
+ row.forEach((btn) => {
553
+ if (btn) {
554
+ if (btn.semantic_id) {
555
+ semanticIds.push(btn.semantic_id);
556
+ }
557
+ if (btn.clone_id) {
558
+ cloneIds.push(btn.clone_id);
559
+ }
560
+ }
561
+ });
562
+ });
563
+ if (semanticIds.length > 0) {
564
+ currentPage.semantic_ids = semanticIds;
565
+ }
566
+ if (cloneIds.length > 0) {
567
+ currentPage.clone_ids = cloneIds;
568
+ }
569
+ }
570
+ }
571
+ return tree;
572
+ }
573
+ catch (error) {
574
+ const fileIdentifier = typeof filePathOrBuffer === 'string' ? filePathOrBuffer : '[buffer input]';
575
+ // Provide more specific error messages
576
+ if (error.code === 'SQLITE_NOTADB') {
577
+ throw new Error(`Invalid SQLite database file: ${typeof filePathOrBuffer === 'string' ? filePathOrBuffer : 'buffer'}`);
578
+ }
579
+ else if (error.code === 'ENOENT') {
580
+ throw new Error(`File not found: ${fileIdentifier}`);
581
+ }
582
+ else if (error.code === 'EACCES') {
583
+ throw new Error(`Permission denied accessing file: ${fileIdentifier}`);
584
+ }
585
+ else {
586
+ throw new Error(`Failed to load Snap file: ${error.message}`);
587
+ }
588
+ }
589
+ finally {
590
+ if (dbResult?.cleanup) {
591
+ dbResult.cleanup();
592
+ }
593
+ else if (dbResult?.db) {
594
+ dbResult.db.close();
595
+ }
596
+ }
597
+ }
598
+ async processTexts(filePathOrBuffer, translations, outputPath) {
599
+ if (!isNodeRuntime()) {
600
+ throw new Error('processTexts is only supported in Node.js environments for Snap files.');
601
+ }
602
+ // Load the tree, apply translations, and save to new file
603
+ const tree = await this.loadIntoTree(filePathOrBuffer);
604
+ // Apply translations to all text content
605
+ Object.values(tree.pages).forEach((page) => {
606
+ // Translate page names
607
+ if (page.name && translations.has(page.name)) {
608
+ const translatedName = translations.get(page.name);
609
+ if (translatedName !== undefined) {
610
+ page.name = translatedName;
611
+ }
612
+ }
613
+ // Translate button labels and messages
614
+ page.buttons.forEach((button) => {
615
+ if (button.label && translations.has(button.label)) {
616
+ const translatedLabel = translations.get(button.label);
617
+ if (translatedLabel !== undefined) {
618
+ button.label = translatedLabel;
619
+ }
620
+ }
621
+ if (button.message && translations.has(button.message)) {
622
+ const translatedMessage = translations.get(button.message);
623
+ if (translatedMessage !== undefined) {
624
+ button.message = translatedMessage;
625
+ }
626
+ }
627
+ });
628
+ });
629
+ // Save the translated tree and return its content
630
+ await this.saveFromTree(tree, outputPath);
631
+ const fs = getFs();
632
+ return fs.readFileSync(outputPath);
633
+ }
634
+ async saveFromTree(tree, outputPath) {
635
+ if (!isNodeRuntime()) {
636
+ throw new Error('saveFromTree is only supported in Node.js environments for Snap files.');
637
+ }
638
+ await Promise.resolve();
639
+ const fs = getFs();
640
+ const path = getPath();
641
+ const outputDir = path.dirname(outputPath);
642
+ if (!fs.existsSync(outputDir)) {
643
+ fs.mkdirSync(outputDir, { recursive: true });
644
+ }
645
+ if (fs.existsSync(outputPath)) {
646
+ fs.unlinkSync(outputPath);
647
+ }
648
+ // Create a new SQLite database for Snap format
649
+ const Database = requireBetterSqlite3();
650
+ const db = new Database(outputPath, { readonly: false });
651
+ try {
652
+ // Create basic Snap database schema (simplified)
653
+ db.exec(`
654
+ CREATE TABLE IF NOT EXISTS Page (
655
+ Id INTEGER PRIMARY KEY,
656
+ UniqueId TEXT UNIQUE,
657
+ Title TEXT,
658
+ Name TEXT,
659
+ BackgroundColor INTEGER
660
+ );
661
+
662
+ CREATE TABLE IF NOT EXISTS Button (
663
+ Id INTEGER PRIMARY KEY,
664
+ Label TEXT,
665
+ Message TEXT,
666
+ NavigatePageId INTEGER,
667
+ ElementReferenceId INTEGER,
668
+ LibrarySymbolId INTEGER,
669
+ PageSetImageId INTEGER,
670
+ MessageRecordingId INTEGER,
671
+ SerializedMessageSoundMetadata TEXT,
672
+ UseMessageRecording INTEGER,
673
+ LabelColor INTEGER,
674
+ BackgroundColor INTEGER,
675
+ BorderColor INTEGER,
676
+ BorderThickness REAL,
677
+ FontSize REAL,
678
+ FontFamily TEXT,
679
+ FontStyle INTEGER
680
+ );
681
+
682
+ CREATE TABLE IF NOT EXISTS ElementReference (
683
+ Id INTEGER PRIMARY KEY,
684
+ PageId INTEGER,
685
+ FOREIGN KEY (PageId) REFERENCES Page (Id)
686
+ );
687
+
688
+ CREATE TABLE IF NOT EXISTS ElementPlacement (
689
+ Id INTEGER PRIMARY KEY,
690
+ ElementReferenceId INTEGER,
691
+ GridPosition TEXT,
692
+ FOREIGN KEY (ElementReferenceId) REFERENCES ElementReference (Id)
693
+ );
694
+
695
+ CREATE TABLE IF NOT EXISTS PageSetData (
696
+ Id INTEGER PRIMARY KEY,
697
+ Identifier TEXT UNIQUE,
698
+ Data BLOB,
699
+ RefCount INTEGER DEFAULT 1
700
+ );
701
+
702
+ CREATE TABLE IF NOT EXISTS PageSetProperties (
703
+ Id INTEGER PRIMARY KEY,
704
+ Name TEXT,
705
+ Description TEXT,
706
+ Author TEXT,
707
+ Locale TEXT,
708
+ DefaultHomePageUniqueId TEXT,
709
+ DefaultKeyboardPageUniqueId TEXT,
710
+ DashboardUniqueId TEXT,
711
+ ToolBarUniqueId TEXT
712
+ );
713
+ `);
714
+ // Insert pages
715
+ let pageIdCounter = 1;
716
+ let buttonIdCounter = 1;
717
+ let elementRefIdCounter = 1;
718
+ let placementIdCounter = 1;
719
+ let pageSetDataIdCounter = 1;
720
+ const pageIdMap = new Map();
721
+ const pageSetDataIdentifierMap = new Map();
722
+ const insertPageSetData = db.prepare('INSERT INTO PageSetData (Id, Identifier, Data, RefCount) VALUES (?, ?, ?, ?)');
723
+ const incrementRefCount = db.prepare('UPDATE PageSetData SET RefCount = RefCount + 1 WHERE Id = ?');
724
+ // First pass: create all pages
725
+ Object.values(tree.pages).forEach((page) => {
726
+ const numericPageId = pageIdCounter++;
727
+ pageIdMap.set(page.id, numericPageId);
728
+ const insertPage = db.prepare('INSERT INTO Page (Id, UniqueId, Title, Name, BackgroundColor) VALUES (?, ?, ?, ?, ?)');
729
+ insertPage.run(numericPageId, page.id, page.name || '', page.name || '', page.style?.backgroundColor
730
+ ? parseInt(page.style.backgroundColor.replace('#', ''), 16)
731
+ : null);
732
+ });
733
+ // Second pass: create buttons with proper page references
734
+ Object.values(tree.pages).forEach((page) => {
735
+ const numericPageId = pageIdMap.get(page.id);
736
+ if (numericPageId === undefined) {
737
+ return;
738
+ }
739
+ page.buttons.forEach((button, index) => {
740
+ // Find button position in grid layout
741
+ let gridPosition = `${index % 4},${Math.floor(index / 4)}`; // Default fallback
742
+ if (page.grid && page.grid.length > 0) {
743
+ // Search for button in grid layout
744
+ for (let y = 0; y < page.grid.length; y++) {
745
+ for (let x = 0; x < page.grid[y].length; x++) {
746
+ const gridButton = page.grid[y][x];
747
+ if (gridButton && gridButton.id === button.id) {
748
+ // Convert grid coordinates to comma-separated format
749
+ gridPosition = `${x},${y}`;
750
+ break;
751
+ }
752
+ }
753
+ }
754
+ }
755
+ const elementRefId = elementRefIdCounter++;
756
+ // Insert ElementReference
757
+ const insertElementRef = db.prepare('INSERT INTO ElementReference (Id, PageId) VALUES (?, ?)');
758
+ insertElementRef.run(elementRefId, numericPageId);
759
+ // Insert Button - handle semantic actions
760
+ let navigatePageId = null;
761
+ // Use semantic action if available
762
+ if (button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO) {
763
+ const targetId = button.semanticAction.targetId || button.targetPageId;
764
+ navigatePageId = targetId ? pageIdMap.get(targetId) || null : null;
765
+ }
766
+ const insertButton = db.prepare('INSERT INTO Button (Id, Label, Message, NavigatePageId, ElementReferenceId, LibrarySymbolId, PageSetImageId, MessageRecordingId, SerializedMessageSoundMetadata, UseMessageRecording, LabelColor, BackgroundColor, BorderColor, BorderThickness, FontSize, FontFamily, FontStyle) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
767
+ const audio = button.audioRecording;
768
+ let messageRecordingId = null;
769
+ let serializedMetadata = null;
770
+ let useMessageRecording = 0;
771
+ if (audio && Buffer.isBuffer(audio.data) && audio.data.length > 0) {
772
+ const identifier = audio.identifier && audio.identifier.trim().length > 0
773
+ ? audio.identifier.trim()
774
+ : `audio_${buttonIdCounter}`;
775
+ let audioId = pageSetDataIdentifierMap.get(identifier);
776
+ if (!audioId) {
777
+ audioId = pageSetDataIdCounter++;
778
+ insertPageSetData.run(audioId, identifier, audio.data, 1);
779
+ pageSetDataIdentifierMap.set(identifier, audioId);
780
+ }
781
+ else {
782
+ incrementRefCount.run(audioId);
783
+ }
784
+ messageRecordingId = audioId;
785
+ serializedMetadata = audio.metadata || null;
786
+ useMessageRecording = 1;
787
+ }
788
+ // Retry logic for SQLite operations
789
+ let retries = 3;
790
+ while (retries > 0) {
791
+ try {
792
+ insertButton.run(buttonIdCounter++, button.label || '', button.message || button.label || '', navigatePageId, elementRefId, null, null, messageRecordingId, serializedMetadata, useMessageRecording, button.style?.fontColor
793
+ ? parseInt(button.style.fontColor.replace('#', ''), 16)
794
+ : null, button.style?.backgroundColor
795
+ ? parseInt(button.style.backgroundColor.replace('#', ''), 16)
796
+ : null, button.style?.borderColor
797
+ ? parseInt(button.style.borderColor.replace('#', ''), 16)
798
+ : null, button.style?.borderWidth, button.style?.fontSize, button.style?.fontFamily, button.style?.fontStyle ? parseInt(button.style.fontStyle) : null);
799
+ break; // Success
800
+ }
801
+ catch (err) {
802
+ if (err.code === 'SQLITE_IOERR' && retries > 1) {
803
+ retries--;
804
+ // Wait a bit before retrying
805
+ const now = Date.now();
806
+ while (Date.now() - now < 100) {
807
+ /* busy wait */
808
+ }
809
+ }
810
+ else {
811
+ throw err;
812
+ }
813
+ }
814
+ }
815
+ // Insert ElementPlacement
816
+ const insertPlacement = db.prepare('INSERT INTO ElementPlacement (Id, ElementReferenceId, GridPosition) VALUES (?, ?, ?)');
817
+ insertPlacement.run(placementIdCounter++, elementRefId, gridPosition);
818
+ });
819
+ });
820
+ // Insert PageSetProperties metadata
821
+ const insertProps = db.prepare(`
822
+ INSERT INTO PageSetProperties (
823
+ Id, Name, Description, Author, Locale,
824
+ DefaultHomePageUniqueId, DefaultKeyboardPageUniqueId,
825
+ DashboardUniqueId, ToolBarUniqueId
826
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
827
+ `);
828
+ insertProps.run(1, tree.metadata?.name || null, tree.metadata?.description || null, tree.metadata?.author || null, tree.metadata?.locale || null, tree.metadata?.defaultHomePageId || tree.rootId || null, tree.metadata?.defaultKeyboardPageId || null, tree.metadata?.dashboardId || null, tree.metadata?.hasGlobalToolbar ? tree.metadata.toolbarId || null : null);
829
+ }
830
+ finally {
831
+ db.close();
832
+ }
833
+ }
834
+ /**
835
+ * Add audio recording to a button in the database
836
+ */
837
+ async addAudioToButton(dbPath, buttonId, audioData, metadata) {
838
+ await Promise.resolve();
839
+ if (!isNodeRuntime()) {
840
+ throw new Error('addAudioToButton is only supported in Node.js environments.');
841
+ }
842
+ const Database = requireBetterSqlite3();
843
+ const crypto = getNodeRequire()('crypto');
844
+ const db = new Database(dbPath, { fileMustExist: true });
845
+ try {
846
+ // Ensure PageSetData table exists
847
+ db.exec(`
848
+ CREATE TABLE IF NOT EXISTS PageSetData (
849
+ Id INTEGER PRIMARY KEY,
850
+ Identifier TEXT UNIQUE,
851
+ Data BLOB
852
+ );
853
+ `);
854
+ // Generate SHA1 hash for the identifier
855
+ const sha1Hash = crypto.createHash('sha1').update(audioData).digest('hex');
856
+ const identifier = `SND:${sha1Hash}`;
857
+ // Check if audio with this identifier already exists
858
+ let audioId;
859
+ const existingAudio = db
860
+ .prepare('SELECT Id FROM PageSetData WHERE Identifier = ?')
861
+ .get(identifier);
862
+ if (existingAudio) {
863
+ audioId = existingAudio.Id;
864
+ }
865
+ else {
866
+ // Insert new audio data
867
+ const result = db
868
+ .prepare('INSERT INTO PageSetData (Identifier, Data) VALUES (?, ?)')
869
+ .run(identifier, audioData);
870
+ audioId = Number(result.lastInsertRowid);
871
+ }
872
+ // Update button to reference the audio
873
+ const updateButton = db.prepare('UPDATE Button SET MessageRecordingId = ?, UseMessageRecording = 1, SerializedMessageSoundMetadata = ? WHERE Id = ?');
874
+ const metadataJson = metadata ? JSON.stringify({ FileName: metadata }) : null;
875
+ updateButton.run(audioId, metadataJson, buttonId);
876
+ return audioId;
877
+ }
878
+ finally {
879
+ db.close();
880
+ }
881
+ }
882
+ /**
883
+ * Create a copy of the pageset with audio recordings added
884
+ */
885
+ async createAudioEnhancedPageset(sourceDbPath, targetDbPath, audioMappings) {
886
+ if (!isNodeRuntime()) {
887
+ throw new Error('createAudioEnhancedPageset is only supported in Node.js environments.');
888
+ }
889
+ const fs = getFs();
890
+ // Copy the source database to target
891
+ fs.copyFileSync(sourceDbPath, targetDbPath);
892
+ // Add audio recordings to the copy
893
+ for (const [buttonId, audioInfo] of audioMappings.entries()) {
894
+ await this.addAudioToButton(targetDbPath, buttonId, audioInfo.audioData, audioInfo.metadata);
895
+ }
896
+ }
897
+ /**
898
+ * Extract buttons from a specific page that need audio recordings
899
+ */
900
+ extractButtonsForAudio(dbPath, pageUniqueId) {
901
+ if (!isNodeRuntime()) {
902
+ throw new Error('extractButtonsForAudio is only supported in Node.js environments.');
903
+ }
904
+ const Database = requireBetterSqlite3();
905
+ const db = new Database(dbPath, { readonly: true });
906
+ try {
907
+ // Find the page by UniqueId
908
+ const page = db.prepare('SELECT * FROM Page WHERE UniqueId = ?').get(pageUniqueId);
909
+ if (!page) {
910
+ throw new Error(`Page with UniqueId ${pageUniqueId} not found`);
911
+ }
912
+ // Get buttons for this page
913
+ const buttons = db
914
+ .prepare(`
915
+ SELECT
916
+ b.Id, b.Label, b.Message, b.MessageRecordingId, b.UseMessageRecording
917
+ FROM Button b
918
+ JOIN ElementReference er ON b.ElementReferenceId = er.Id
919
+ WHERE er.PageId = ?
920
+ `)
921
+ .all(page.Id);
922
+ return buttons.map((btn) => ({
923
+ id: btn.Id,
924
+ label: btn.Label || '',
925
+ message: btn.Message || btn.Label || '',
926
+ hasAudio: !!(btn.MessageRecordingId && btn.MessageRecordingId > 0),
927
+ }));
928
+ }
929
+ finally {
930
+ db.close();
931
+ }
932
+ }
933
+ /**
934
+ * Extract strings with metadata for aac-tools-platform compatibility
935
+ * Uses the generic implementation from BaseProcessor
936
+ */
937
+ async extractStringsWithMetadata(filePath) {
938
+ return this.extractStringsWithMetadataGeneric(filePath);
939
+ }
940
+ /**
941
+ * Generate translated download for aac-tools-platform compatibility
942
+ * Uses the generic implementation from BaseProcessor
943
+ */
944
+ async generateTranslatedDownload(filePath, translatedStrings, sourceStrings) {
945
+ return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings);
946
+ }
947
+ /**
948
+ * Validate Snap file format
949
+ * @param filePath - Path to the file to validate
950
+ * @returns Promise with validation result
951
+ */
952
+ async validate(filePath) {
953
+ return SnapValidator.validateFile(filePath);
954
+ }
955
+ /**
956
+ * Get available PageLayouts for a Snap file
957
+ * Useful for UI components that want to let users select layout size
958
+ * @param filePath - Path to the Snap file
959
+ * @returns Array of available PageLayouts with their dimensions
960
+ */
961
+ getAvailablePageLayouts(filePath) {
962
+ if (!isNodeRuntime()) {
963
+ throw new Error('getAvailablePageLayouts is only supported in Node.js environments.');
964
+ }
965
+ const fs = getFs();
966
+ const path = getPath();
967
+ const dbPath = typeof filePath === 'string' ? filePath : path.join(process.cwd(), 'temp.spb');
968
+ if (Buffer.isBuffer(filePath)) {
969
+ fs.writeFileSync(dbPath, filePath);
970
+ }
971
+ let db = null;
972
+ try {
973
+ const Database = requireBetterSqlite3();
974
+ db = new Database(dbPath, { readonly: true });
975
+ // Get unique PageLayouts based on PageLayoutSetting (dimensions)
976
+ const pageLayouts = db
977
+ .prepare(`
978
+ SELECT
979
+ MIN(pl.Id) as Id,
980
+ pl.PageLayoutSetting
981
+ FROM PageLayout pl
982
+ GROUP BY pl.PageLayoutSetting
983
+ ORDER BY pl.PageLayoutSetting
984
+ `)
985
+ .all();
986
+ // Parse the PageLayoutSetting format: "columns,rows,hasScanGroups,?"
987
+ const layouts = pageLayouts.map((pl) => {
988
+ const parts = pl.PageLayoutSetting.split(',');
989
+ const cols = parseInt(parts[0], 10) || 0;
990
+ const rows = parseInt(parts[1], 10) || 0;
991
+ const hasScanning = parts[2] === 'True';
992
+ return {
993
+ id: pl.Id,
994
+ cols,
995
+ rows,
996
+ size: cols * rows,
997
+ hasScanning,
998
+ label: `${cols}×${rows}${hasScanning ? ' (with scanning)' : ''}`,
999
+ };
1000
+ });
1001
+ // Sort by size (total buttons), with scanning layouts first
1002
+ layouts.sort((a, b) => {
1003
+ if (a.hasScanning && !b.hasScanning)
1004
+ return -1;
1005
+ if (!a.hasScanning && b.hasScanning)
1006
+ return 1;
1007
+ return b.size - a.size; // Larger sizes first
1008
+ });
1009
+ return layouts;
1010
+ }
1011
+ catch (error) {
1012
+ console.error('[SnapProcessor] Failed to get available page layouts:', error);
1013
+ return [];
1014
+ }
1015
+ finally {
1016
+ if (db) {
1017
+ db.close();
1018
+ }
1019
+ // Clean up temporary file if created from buffer
1020
+ if (Buffer.isBuffer(filePath) && fs.existsSync(dbPath)) {
1021
+ try {
1022
+ fs.unlinkSync(dbPath);
1023
+ }
1024
+ catch (e) {
1025
+ console.warn('Failed to clean up temporary file:', e);
1026
+ }
1027
+ }
1028
+ }
1029
+ }
1030
+ }
1031
+ export { SnapProcessor };