@willwade/aac-processors 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/dist/browser/index.browser.js +15 -1
- package/dist/browser/processors/gridset/password.js +11 -0
- package/dist/browser/processors/gridset/symbols.js +3 -2
- package/dist/browser/processors/gridsetProcessor.js +42 -46
- package/dist/browser/processors/obfProcessor.js +47 -63
- package/dist/browser/processors/snapProcessor.js +1031 -0
- package/dist/browser/processors/touchchatProcessor.js +1004 -0
- package/dist/browser/utils/io.js +21 -1
- package/dist/browser/utils/sqlite.js +109 -0
- package/dist/browser/utils/zip.js +54 -0
- package/dist/browser/validation/gridsetValidator.js +21 -2
- package/dist/browser/validation/obfValidator.js +4 -5
- package/dist/browser/validation/snapValidator.js +200 -0
- package/dist/browser/validation/touchChatValidator.js +202 -0
- package/dist/index.browser.d.ts +7 -0
- package/dist/index.browser.js +19 -2
- package/dist/processors/gridset/helpers.js +3 -4
- package/dist/processors/gridset/index.d.ts +1 -1
- package/dist/processors/gridset/index.js +3 -2
- package/dist/processors/gridset/password.d.ts +3 -2
- package/dist/processors/gridset/password.js +12 -0
- package/dist/processors/gridset/symbols.js +2 -1
- package/dist/processors/gridset/wordlistHelpers.js +107 -51
- package/dist/processors/gridsetProcessor.js +40 -44
- package/dist/processors/obfProcessor.js +46 -62
- package/dist/processors/snapProcessor.js +60 -54
- package/dist/processors/touchchatProcessor.js +38 -36
- package/dist/utils/io.d.ts +5 -0
- package/dist/utils/io.js +23 -0
- package/dist/utils/sqlite.d.ts +21 -0
- package/dist/utils/sqlite.js +137 -0
- package/dist/utils/zip.d.ts +7 -0
- package/dist/utils/zip.js +80 -0
- package/dist/validation/gridsetValidator.js +20 -24
- package/dist/validation/obfValidator.js +4 -28
- package/docs/BROWSER_USAGE.md +2 -10
- package/examples/README.md +3 -75
- package/examples/vitedemo/README.md +17 -10
- package/examples/vitedemo/index.html +2 -2
- package/examples/vitedemo/package-lock.json +531 -1
- package/examples/vitedemo/package.json +7 -1
- package/examples/vitedemo/src/main.ts +48 -2
- package/examples/vitedemo/src/vite-env.d.ts +1 -0
- package/package.json +3 -1
- package/examples/browser-test-server.js +0 -81
- package/examples/vitedemo/QUICKSTART.md +0 -75
|
@@ -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 };
|