@willwade/aac-processors 0.0.18 → 0.0.20

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.
@@ -61,7 +61,7 @@ export interface VocabPlacementMetadata {
61
61
  }
62
62
  export interface VocabLocation {
63
63
  table: string;
64
- id: number;
64
+ id: string | number;
65
65
  column: string;
66
66
  casing: StringCasing;
67
67
  }
@@ -130,7 +130,7 @@ class BaseProcessor {
130
130
  const key = page.name.trim().toLowerCase();
131
131
  const vocabLocation = {
132
132
  table: 'pages',
133
- id: parseInt(page.id) || 0,
133
+ id: page.id,
134
134
  column: 'NAME',
135
135
  casing: (0, stringCasing_1.detectCasing)(page.name),
136
136
  };
@@ -142,7 +142,7 @@ class BaseProcessor {
142
142
  const key = button.label.trim().toLowerCase();
143
143
  const vocabLocation = {
144
144
  table: 'buttons',
145
- id: parseInt(button.id) || 0,
145
+ id: button.id,
146
146
  column: 'LABEL',
147
147
  casing: (0, stringCasing_1.detectCasing)(button.label),
148
148
  };
@@ -156,7 +156,7 @@ class BaseProcessor {
156
156
  const key = button.message.trim().toLowerCase();
157
157
  const vocabLocation = {
158
158
  table: 'buttons',
159
- id: parseInt(button.id) || 0,
159
+ id: button.id,
160
160
  column: 'MESSAGE',
161
161
  casing: (0, stringCasing_1.detectCasing)(button.message),
162
162
  };
@@ -1,4 +1,5 @@
1
- import { AACButton as IAACButton, AACPage as IAACPage, AACTree as IAACTree, AACStyle } from '../types/aac';
1
+ import { AACButton as IAACButton, AACPage as IAACPage, AACTree as IAACTree, AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata, AACStyle } from '../types/aac';
2
+ export { AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata };
2
3
  export declare enum AACSemanticCategory {
3
4
  COMMUNICATION = "communication",// Speech, text output
4
5
  NAVIGATION = "navigation",// Page/grid navigation
@@ -234,9 +235,13 @@ export declare class AACTree implements IAACTree {
234
235
  pages: {
235
236
  [key: string]: AACPage;
236
237
  };
237
- private _rootId;
238
+ metadata: AACTreeMetadata;
238
239
  get rootId(): string | null;
239
240
  set rootId(id: string | null);
241
+ get toolbarId(): string | null;
242
+ set toolbarId(id: string | null);
243
+ get dashboardId(): string | null;
244
+ set dashboardId(id: string | null);
240
245
  constructor();
241
246
  addPage(page: AACPage): void;
242
247
  getPage(id: string): AACPage | undefined;
@@ -202,19 +202,31 @@ class AACPage {
202
202
  exports.AACPage = AACPage;
203
203
  class AACTree {
204
204
  get rootId() {
205
- return this._rootId;
205
+ return this.metadata.defaultHomePageId || null;
206
206
  }
207
207
  set rootId(id) {
208
- this._rootId = id;
208
+ this.metadata.defaultHomePageId = id || undefined;
209
+ }
210
+ get toolbarId() {
211
+ return this.metadata.toolbarId || null;
212
+ }
213
+ set toolbarId(id) {
214
+ this.metadata.toolbarId = id || undefined;
215
+ }
216
+ get dashboardId() {
217
+ return this.metadata.dashboardId || null;
218
+ }
219
+ set dashboardId(id) {
220
+ this.metadata.dashboardId = id || undefined;
209
221
  }
210
222
  constructor() {
211
223
  this.pages = {};
212
- this._rootId = null;
224
+ this.metadata = {};
213
225
  }
214
226
  addPage(page) {
215
227
  this.pages[page.id] = page;
216
- if (!this._rootId)
217
- this._rootId = page.id;
228
+ if (!this.rootId)
229
+ this.rootId = page.id;
218
230
  }
219
231
  getPage(id) {
220
232
  return this.pages[id];
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export * from './core/baseProcessor';
10
10
  export * from './core/stringCasing';
11
11
  export * from './processors';
12
12
  export * as Analytics from './utilities/analytics';
13
+ export * from './utilities/analytics';
13
14
  export * as Validation from './validation';
14
15
  export * as Gridset from './gridset';
15
16
  export * as Snap from './snap';
package/dist/index.js CHANGED
@@ -52,6 +52,8 @@ __exportStar(require("./processors"), exports);
52
52
  // ===================================================================
53
53
  // Analytics namespace
54
54
  exports.Analytics = __importStar(require("./utilities/analytics"));
55
+ // Also export analytics classes directly for convenience
56
+ __exportStar(require("./utilities/analytics"), exports);
55
57
  // Validation namespace
56
58
  exports.Validation = __importStar(require("./validation"));
57
59
  // Processor namespaces (platform-specific utilities)
@@ -139,6 +139,7 @@ class ApplePanelsProcessor extends baseProcessor_1.BaseProcessor {
139
139
  }
140
140
  const data = { panels: panelsData };
141
141
  const tree = new treeStructure_1.AACTree();
142
+ tree.metadata.format = 'applepanels';
142
143
  data.panels.forEach((panel) => {
143
144
  const page = new treeStructure_1.AACPage({
144
145
  id: panel.id,
@@ -309,16 +310,17 @@ class ApplePanelsProcessor extends baseProcessor_1.BaseProcessor {
309
310
  fs_1.default.mkdirSync(resourcesPath, { recursive: true });
310
311
  // Create Info.plist (bundle mode only)
311
312
  const infoPlist = {
312
- ASCConfigurationDisplayName: 'AAC Processors Export',
313
+ ASCConfigurationDisplayName: tree.metadata?.name || 'AAC Processors Export',
313
314
  ASCConfigurationIdentifier: `com.aacprocessors.${Date.now()}`,
314
315
  ASCConfigurationProductSupportType: 'VirtualKeyboard',
315
- ASCConfigurationVersion: '7.1',
316
- CFBundleDevelopmentRegion: 'en',
316
+ ASCConfigurationVersion: tree.metadata?.version || '7.1',
317
+ CFBundleDevelopmentRegion: tree.metadata?.locale || 'en',
317
318
  CFBundleIdentifier: 'com.aacprocessors.panel.export',
318
- CFBundleName: 'AAC Processors Panels',
319
- CFBundleShortVersionString: '1.0',
319
+ CFBundleName: tree.metadata?.name || 'AAC Processors Panels',
320
+ CFBundleShortVersionString: tree.metadata?.version || '1.0',
320
321
  CFBundleVersion: '1',
321
- NSHumanReadableCopyright: 'Generated by AAC Processors',
322
+ NSHumanReadableCopyright: tree.metadata?.copyright ||
323
+ `Generated by AAC Processors${tree.metadata?.author ? ` - Author: ${tree.metadata.author}` : ''}`,
322
324
  };
323
325
  const infoPlistContent = plist_1.default.build(infoPlist);
324
326
  fs_1.default.writeFileSync(path_1.default.join(contentsPath, 'Info.plist'), infoPlistContent);
@@ -740,6 +740,41 @@ class AstericsGridProcessor extends baseProcessor_1.BaseProcessor {
740
740
  // Set the page's grid layout
741
741
  page.grid = gridLayout;
742
742
  });
743
+ // Set metadata for Asterics Grid files
744
+ const astericsMetadata = {
745
+ format: 'asterics',
746
+ hasGlobalGrid: false, // Can be extended in the future
747
+ };
748
+ if (grdFile.grids && grdFile.grids.length > 0) {
749
+ astericsMetadata.name = this.getLocalizedLabel(grdFile.grids[0].label);
750
+ // Extract all unique languages from all grids and elements
751
+ const languages = new Set();
752
+ grdFile.grids.forEach((grid) => {
753
+ if (grid.label) {
754
+ Object.keys(grid.label).forEach((lang) => languages.add(lang));
755
+ }
756
+ grid.gridElements?.forEach((element) => {
757
+ if (element.label) {
758
+ Object.keys(element.label).forEach((lang) => languages.add(lang));
759
+ }
760
+ // Also check word forms for languages
761
+ element.wordForms?.forEach((wf) => {
762
+ if (wf.lang)
763
+ languages.add(wf.lang);
764
+ });
765
+ });
766
+ });
767
+ if (languages.size > 0) {
768
+ astericsMetadata.languages = Array.from(languages).sort();
769
+ // Set primary locale to English if available, otherwise the first language found
770
+ astericsMetadata.locale = languages.has('en')
771
+ ? 'en'
772
+ : languages.has('de')
773
+ ? 'de'
774
+ : astericsMetadata.languages[0];
775
+ }
776
+ }
777
+ tree.metadata = astericsMetadata;
743
778
  // Set the home page from metadata.homeGridId
744
779
  if (grdFile.metadata && grdFile.metadata.homeGridId) {
745
780
  tree.rootId = grdFile.metadata.homeGridId;
@@ -1276,6 +1311,7 @@ class AstericsGridProcessor extends baseProcessor_1.BaseProcessor {
1276
1311
  filename: button.audioRecording.identifier || `audio-${button.id}`,
1277
1312
  });
1278
1313
  }
1314
+ const locale = tree.metadata?.locale || 'en';
1279
1315
  return {
1280
1316
  id: button.id,
1281
1317
  modelName: 'GridElement',
@@ -1284,7 +1320,7 @@ class AstericsGridProcessor extends baseProcessor_1.BaseProcessor {
1284
1320
  height: 1,
1285
1321
  x: calculatedX,
1286
1322
  y: calculatedY,
1287
- label: { en: button.label },
1323
+ label: { [locale]: button.label },
1288
1324
  wordForms: [],
1289
1325
  image: {
1290
1326
  data: null,
@@ -1308,7 +1344,7 @@ class AstericsGridProcessor extends baseProcessor_1.BaseProcessor {
1308
1344
  id: page.id,
1309
1345
  modelName: 'GridData',
1310
1346
  modelVersion: '{"major": 5, "minor": 0, "patch": 0}',
1311
- label: { en: page.name },
1347
+ label: { [tree.metadata?.locale || 'en']: page.name },
1312
1348
  rowCount: calculatedRows,
1313
1349
  minColumnCount: calculatedCols,
1314
1350
  gridElements: gridElements,
@@ -107,6 +107,7 @@ class DotProcessor extends baseProcessor_1.BaseProcessor {
107
107
  }
108
108
  const { nodes, edges } = this.parseDotFile(content);
109
109
  const tree = new treeStructure_1.AACTree();
110
+ tree.metadata.format = 'dot';
110
111
  // Create pages for each node and add a self button representing the node label
111
112
  for (const node of nodes) {
112
113
  const page = new treeStructure_1.AACPage({
@@ -164,11 +165,14 @@ class DotProcessor extends baseProcessor_1.BaseProcessor {
164
165
  return resultBuffer;
165
166
  }
166
167
  saveFromTree(tree, _outputPath) {
167
- let dotContent = 'digraph AACBoard {\n';
168
+ let dotContent = `digraph "${tree.metadata?.name || 'AACBoard'}" {\n`;
168
169
  // Helper to escape DOT string
169
170
  const escapeDotString = (str) => {
170
171
  return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
171
172
  };
173
+ if (tree.metadata?.name) {
174
+ dotContent += ` label="${escapeDotString(tree.metadata.name)}";\n`;
175
+ }
172
176
  // Add nodes
173
177
  for (const pageId in tree.pages) {
174
178
  const page = tree.pages[pageId];
@@ -54,7 +54,9 @@ class ExcelProcessor extends baseProcessor_1.BaseProcessor {
54
54
  */
55
55
  loadIntoTree(_filePathOrBuffer) {
56
56
  console.warn('ExcelProcessor.loadIntoTree is not implemented yet.');
57
- return new treeStructure_1.AACTree();
57
+ const tree = new treeStructure_1.AACTree();
58
+ tree.metadata.format = 'excel';
59
+ return tree;
58
60
  }
59
61
  /**
60
62
  * Process texts in Excel file (apply translations)
@@ -486,11 +488,14 @@ class ExcelProcessor extends baseProcessor_1.BaseProcessor {
486
488
  */
487
489
  async saveFromTreeAsync(tree, outputPath) {
488
490
  const workbook = new ExcelJS.Workbook();
489
- // Set workbook properties
490
- workbook.creator = 'AACProcessors';
491
+ const metadata = tree.metadata;
492
+ // Set workbook properties from tree metadata
493
+ workbook.creator = metadata?.author || 'AACProcessors';
491
494
  workbook.lastModifiedBy = 'AACProcessors';
492
495
  workbook.created = new Date();
493
496
  workbook.modified = new Date();
497
+ workbook.title = metadata?.name || '';
498
+ workbook.subject = metadata?.description || '';
494
499
  // If no pages, create a default empty worksheet
495
500
  if (Object.keys(tree.pages).length === 0) {
496
501
  const worksheet = workbook.addWorksheet('Empty');
@@ -380,6 +380,12 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
380
380
  const parser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false });
381
381
  const isEncryptedArchive = typeof filePathOrBuffer === 'string' && filePathOrBuffer.toLowerCase().endsWith('.gridsetx');
382
382
  const encryptedContentPassword = this.getGridsetPassword(filePathOrBuffer);
383
+ // Initialize metadata
384
+ const metadata = {
385
+ format: 'gridset',
386
+ isSmartBox: isEncryptedArchive, // SmartBox files are .gridsetx encrypted archives
387
+ passwordProtected: !!password,
388
+ };
383
389
  const readEntryBuffer = (entry) => {
384
390
  const raw = entry.getData();
385
391
  if (!isEncryptedArchive)
@@ -1321,6 +1327,71 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1321
1327
  if (settingsEntry) {
1322
1328
  const settingsXml = readEntryBuffer(settingsEntry).toString('utf8');
1323
1329
  const settingsData = parser.parse(settingsXml);
1330
+ const gsName = settingsData?.GridSetSettings?.Name ||
1331
+ settingsData?.gridSetSettings?.name ||
1332
+ settingsData?.GridsetSettings?.Name;
1333
+ if (gsName)
1334
+ metadata.name = gsName;
1335
+ const gsDesc = settingsData?.GridSetSettings?.Description ||
1336
+ settingsData?.gridSetSettings?.description ||
1337
+ settingsData?.GridsetSettings?.Description;
1338
+ if (gsDesc)
1339
+ metadata.description = gsDesc;
1340
+ const gsLang = settingsData?.GridSetSettings?.PrimaryLanguage ||
1341
+ settingsData?.gridSetSettings?.primaryLanguage ||
1342
+ settingsData?.GridsetSettings?.PrimaryLanguage;
1343
+ if (gsLang && typeof gsLang === 'string') {
1344
+ metadata.locale = gsLang;
1345
+ metadata.languages = [gsLang];
1346
+ }
1347
+ const gsAuthor = settingsData?.GridSetSettings?.Author ||
1348
+ settingsData?.gridSetSettings?.author ||
1349
+ settingsData?.GridsetSettings?.Author;
1350
+ if (gsAuthor)
1351
+ metadata.author = gsAuthor;
1352
+ const docUrl = settingsData?.GridSetSettings?.DocumentationUrl ||
1353
+ settingsData?.gridSetSettings?.documentationUrl ||
1354
+ settingsData?.GridsetSettings?.DocumentationUrl;
1355
+ if (docUrl) {
1356
+ metadata.homepageUrl = docUrl;
1357
+ metadata.documentationUrl = docUrl;
1358
+ }
1359
+ const docSlug = settingsData?.GridSetSettings?.DocumentationSlug ||
1360
+ settingsData?.gridSetSettings?.documentationSlug ||
1361
+ settingsData?.GridsetSettings?.DocumentationSlug;
1362
+ if (docSlug)
1363
+ metadata.documentationSlug = docSlug;
1364
+ const thumbnail = settingsData?.GridSetSettings?.Thumbnail ||
1365
+ settingsData?.gridSetSettings?.thumbnail ||
1366
+ settingsData?.GridsetSettings?.Thumbnail;
1367
+ if (thumbnail)
1368
+ metadata.thumbnail = thumbnail;
1369
+ const thumbBg = settingsData?.GridSetSettings?.ThumbnailBackground ||
1370
+ settingsData?.gridSetSettings?.thumbnailBackground ||
1371
+ settingsData?.GridsetSettings?.ThumbnailBackground;
1372
+ if (thumbBg)
1373
+ metadata.thumbnailBackground = thumbBg;
1374
+ const picSearchKeys = settingsData?.GridSetSettings?.PictureSearch?.PictureSearchKeys?.PictureSearchKey ||
1375
+ settingsData?.gridSetSettings?.pictureSearch?.pictureSearchKeys?.pictureSearchKey ||
1376
+ settingsData?.GridsetSettings?.PictureSearch?.PictureSearchKeys?.PictureSearchKey;
1377
+ if (picSearchKeys) {
1378
+ metadata.pictureSearchKeys = Array.isArray(picSearchKeys)
1379
+ ? picSearchKeys
1380
+ : [picSearchKeys];
1381
+ }
1382
+ const appearance = settingsData?.GridSetSettings?.Appearance ||
1383
+ settingsData?.gridSetSettings?.appearance ||
1384
+ settingsData?.GridsetSettings?.Appearance;
1385
+ if (appearance) {
1386
+ metadata.appearance = {
1387
+ textAtTop: appearance.TextAtTop === '1' ||
1388
+ appearance.textAtTop === '1' ||
1389
+ appearance.TextAtTop === 1,
1390
+ computerControlCellSize: appearance.ComputerControlCellSize
1391
+ ? parseFloat(String(appearance.ComputerControlCellSize))
1392
+ : undefined,
1393
+ };
1394
+ }
1324
1395
  const startGridName = settingsData?.GridSetSettings?.StartGrid ||
1325
1396
  settingsData?.gridSetSettings?.startGrid ||
1326
1397
  settingsData?.GridsetSettings?.StartGrid;
@@ -1328,19 +1399,22 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1328
1399
  // Resolve the grid name to grid ID
1329
1400
  const homeGridId = gridNameToIdMap.get(startGridName);
1330
1401
  if (homeGridId) {
1331
- tree.rootId = homeGridId;
1402
+ metadata.defaultHomePageId = homeGridId;
1332
1403
  }
1333
1404
  }
1334
1405
  const keyboardGridName = settingsData?.GridSetSettings?.KeyboardGrid ||
1335
- settingsData?.gridSetSettings?.keyboardGrid;
1406
+ settingsData?.gridSetSettings?.keyboardGrid ||
1407
+ settingsData?.GridsetSettings?.KeyboardGrid;
1336
1408
  if (keyboardGridName && typeof keyboardGridName === 'string') {
1337
- tree.keyboardGridName = keyboardGridName;
1409
+ metadata.defaultKeyboardPageId = gridNameToIdMap.get(keyboardGridName);
1338
1410
  }
1339
1411
  }
1340
1412
  }
1341
1413
  catch (e) {
1342
1414
  // If settings.xml parsing fails, tree.rootId will default to first page
1343
1415
  }
1416
+ // Set metadata on tree
1417
+ tree.metadata = metadata;
1344
1418
  return tree;
1345
1419
  }
1346
1420
  processTexts(filePathOrBuffer, translations, outputPath) {
@@ -202,6 +202,17 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
202
202
  console.log('[OBF] Detected .obf file, parsed as JSON');
203
203
  const page = this.processBoard(boardData, filePathOrBuffer);
204
204
  tree.addPage(page);
205
+ // Set metadata from root board
206
+ tree.metadata.format = 'obf';
207
+ tree.metadata.name = boardData.name;
208
+ tree.metadata.description = boardData.description_html;
209
+ tree.metadata.locale = boardData.locale;
210
+ tree.metadata.id = boardData.id;
211
+ if (boardData.url)
212
+ tree.metadata.url = boardData.url;
213
+ if (boardData.locale)
214
+ tree.metadata.languages = [boardData.locale];
215
+ tree.rootId = page.id;
205
216
  return tree;
206
217
  }
207
218
  else {
@@ -219,6 +230,18 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
219
230
  console.log('[OBF] Detected buffer/string as OBF JSON');
220
231
  const page = this.processBoard(asJson, '[bufferOrString]');
221
232
  tree.addPage(page);
233
+ // Set metadata from root board
234
+ tree.metadata.format = 'obf';
235
+ tree.metadata.name = asJson.name;
236
+ tree.metadata.description = asJson.description_html;
237
+ tree.metadata.locale = asJson.locale;
238
+ tree.metadata.id = asJson.id;
239
+ if (asJson.url)
240
+ tree.metadata.url = asJson.url;
241
+ if (asJson.locale) {
242
+ tree.metadata.languages = [asJson.locale];
243
+ }
244
+ tree.rootId = page.id;
222
245
  return tree;
223
246
  }
224
247
  // Otherwise, try as ZIP (.obz). Detect likely zip signature first; throw if neither JSON nor ZIP
@@ -249,6 +272,19 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
249
272
  if (boardData) {
250
273
  const page = this.processBoard(boardData, entry.entryName);
251
274
  tree.addPage(page);
275
+ // Set metadata if not already set (use first board as reference)
276
+ if (!tree.metadata.format) {
277
+ tree.metadata.format = 'obf';
278
+ tree.metadata.name = boardData.name;
279
+ tree.metadata.description = boardData.description_html;
280
+ tree.metadata.locale = boardData.locale;
281
+ tree.metadata.id = boardData.id;
282
+ if (boardData.url)
283
+ tree.metadata.url = boardData.url;
284
+ if (boardData.locale)
285
+ tree.metadata.languages = [boardData.locale];
286
+ tree.rootId = page.id;
287
+ }
252
288
  }
253
289
  else {
254
290
  console.warn('[OBF] Skipped entry (not valid OBF JSON):', entry.entryName);
@@ -298,15 +334,20 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
298
334
  }
299
335
  return { rows: totalRows, columns: totalColumns, order, buttonPositions };
300
336
  }
301
- createObfBoardFromPage(page, fallbackName) {
337
+ createObfBoardFromPage(page, fallbackName, metadata) {
302
338
  const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page);
303
- const boardName = page.name || fallbackName;
339
+ const boardName = metadata?.name && page.id === metadata?.defaultHomePageId
340
+ ? metadata.name
341
+ : page.name || fallbackName;
304
342
  return {
305
343
  format: OBF_FORMAT_VERSION,
306
344
  id: page.id,
307
- locale: page.locale || 'en',
345
+ url: metadata?.url,
346
+ locale: metadata?.locale || page.locale || 'en',
308
347
  name: boardName,
309
- description_html: page.descriptionHtml || boardName,
348
+ description_html: metadata?.description && page.id === metadata?.defaultHomePageId
349
+ ? metadata.description
350
+ : page.descriptionHtml || boardName,
310
351
  grid: {
311
352
  rows,
312
353
  columns,
@@ -368,14 +409,14 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
368
409
  if (!rootPage) {
369
410
  throw new Error('No pages to save');
370
411
  }
371
- const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board');
412
+ const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata);
372
413
  fs_1.default.writeFileSync(outputPath, JSON.stringify(obfBoard, null, 2));
373
414
  }
374
415
  else {
375
416
  // Save as OBZ (zip with multiple OBF files)
376
417
  const zip = new adm_zip_1.default();
377
418
  Object.values(tree.pages).forEach((page) => {
378
- const obfBoard = this.createObfBoardFromPage(page, 'Board');
419
+ const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
379
420
  const obfContent = JSON.stringify(obfBoard, null, 2);
380
421
  zip.addFile(`${page.id}.obf`, Buffer.from(obfContent, 'utf8'));
381
422
  });
@@ -37,6 +37,7 @@ class ObfsetProcessor extends baseProcessor_1.BaseProcessor {
37
37
  */
38
38
  loadIntoTree(filePathOrBuffer) {
39
39
  const tree = new treeStructure_1.AACTree();
40
+ tree.metadata.format = 'obfset';
40
41
  let content;
41
42
  if (Buffer.isBuffer(filePathOrBuffer)) {
42
43
  content = filePathOrBuffer.toString('utf-8');
@@ -109,6 +109,7 @@ class OpmlProcessor extends baseProcessor_1.BaseProcessor {
109
109
  throw new Error(`Invalid OPML XML: ${e?.message || String(e)}`);
110
110
  }
111
111
  const tree = new treeStructure_1.AACTree();
112
+ tree.metadata.format = 'opml';
112
113
  // Handle case where body.outline might not exist or be in different formats
113
114
  const bodyOutline = data.opml?.body?.outline;
114
115
  if (!bodyOutline) {
@@ -49,5 +49,23 @@ declare class SnapProcessor extends BaseProcessor {
49
49
  * @returns Promise with validation result
50
50
  */
51
51
  validate(filePath: string): Promise<ValidationResult>;
52
+ /**
53
+ * Get available PageLayouts for a Snap file
54
+ * Useful for UI components that want to let users select layout size
55
+ * @param filePath - Path to the Snap file
56
+ * @returns Array of available PageLayouts with their dimensions
57
+ */
58
+ getAvailablePageLayouts(filePath: string): PageLayoutInfo[];
59
+ }
60
+ /**
61
+ * Interface for PageLayout information returned by getAvailablePageLayouts
62
+ */
63
+ export interface PageLayoutInfo {
64
+ id: number;
65
+ cols: number;
66
+ rows: number;
67
+ size: number;
68
+ hasScanning: boolean;
69
+ label: string;
52
70
  }
53
71
  export { SnapProcessor };
@@ -78,16 +78,33 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
78
78
  let defaultKeyboardPageId;
79
79
  let defaultHomePageId;
80
80
  let dashboardPageId;
81
+ let toolbarId;
81
82
  try {
82
83
  const properties = db.prepare('SELECT * FROM PageSetProperties').get();
83
84
  if (properties) {
84
85
  defaultKeyboardPageId = properties.DefaultKeyboardPageUniqueId;
85
86
  defaultHomePageId = properties.DefaultHomePageUniqueId;
86
87
  dashboardPageId = properties.DashboardUniqueId;
87
- const toolbarId = properties.ToolBarUniqueId;
88
+ toolbarId = properties.ToolBarUniqueId;
88
89
  const hasGlobalToolbar = toolbarId && toolbarId !== '00000000-0000-0000-0000-000000000000';
90
+ // Store metadata in tree
91
+ const metadata = {
92
+ format: 'snap',
93
+ name: properties.Name || properties.PageSetName || undefined,
94
+ description: properties.Description || undefined,
95
+ author: properties.Author || undefined,
96
+ locale: properties.Locale || undefined,
97
+ languages: properties.Locale ? [properties.Locale] : undefined,
98
+ defaultKeyboardPageId: defaultKeyboardPageId || undefined,
99
+ defaultHomePageId: defaultHomePageId || undefined,
100
+ dashboardId: dashboardPageId || undefined,
101
+ hasGlobalToolbar: !!hasGlobalToolbar,
102
+ };
103
+ tree.metadata = metadata;
104
+ // Set toolbarId if there's a global toolbar
89
105
  if (hasGlobalToolbar) {
90
- tree.rootId = toolbarId;
106
+ tree.toolbarId = toolbarId || null;
107
+ tree.rootId = toolbarId || defaultHomePageId || null;
91
108
  }
92
109
  else if (defaultHomePageId) {
93
110
  tree.rootId = defaultHomePageId;
@@ -896,5 +913,74 @@ class SnapProcessor extends baseProcessor_1.BaseProcessor {
896
913
  async validate(filePath) {
897
914
  return snapValidator_1.SnapValidator.validateFile(filePath);
898
915
  }
916
+ /**
917
+ * Get available PageLayouts for a Snap file
918
+ * Useful for UI components that want to let users select layout size
919
+ * @param filePath - Path to the Snap file
920
+ * @returns Array of available PageLayouts with their dimensions
921
+ */
922
+ getAvailablePageLayouts(filePath) {
923
+ const dbPath = typeof filePath === 'string' ? filePath : path_1.default.join(process.cwd(), 'temp.spb');
924
+ if (Buffer.isBuffer(filePath)) {
925
+ fs_1.default.writeFileSync(dbPath, filePath);
926
+ }
927
+ let db = null;
928
+ try {
929
+ db = new better_sqlite3_1.default(dbPath, { readonly: true });
930
+ // Get unique PageLayouts based on PageLayoutSetting (dimensions)
931
+ const pageLayouts = db
932
+ .prepare(`
933
+ SELECT
934
+ MIN(pl.Id) as Id,
935
+ pl.PageLayoutSetting
936
+ FROM PageLayout pl
937
+ GROUP BY pl.PageLayoutSetting
938
+ ORDER BY pl.PageLayoutSetting
939
+ `)
940
+ .all();
941
+ // Parse the PageLayoutSetting format: "columns,rows,hasScanGroups,?"
942
+ const layouts = pageLayouts.map((pl) => {
943
+ const parts = pl.PageLayoutSetting.split(',');
944
+ const cols = parseInt(parts[0], 10) || 0;
945
+ const rows = parseInt(parts[1], 10) || 0;
946
+ const hasScanning = parts[2] === 'True';
947
+ return {
948
+ id: pl.Id,
949
+ cols,
950
+ rows,
951
+ size: cols * rows,
952
+ hasScanning,
953
+ label: `${cols}×${rows}${hasScanning ? ' (with scanning)' : ''}`,
954
+ };
955
+ });
956
+ // Sort by size (total buttons), with scanning layouts first
957
+ layouts.sort((a, b) => {
958
+ if (a.hasScanning && !b.hasScanning)
959
+ return -1;
960
+ if (!a.hasScanning && b.hasScanning)
961
+ return 1;
962
+ return b.size - a.size; // Larger sizes first
963
+ });
964
+ return layouts;
965
+ }
966
+ catch (error) {
967
+ console.error('[SnapProcessor] Failed to get available page layouts:', error);
968
+ return [];
969
+ }
970
+ finally {
971
+ if (db) {
972
+ db.close();
973
+ }
974
+ // Clean up temporary file if created from buffer
975
+ if (Buffer.isBuffer(filePath) && fs_1.default.existsSync(dbPath)) {
976
+ try {
977
+ fs_1.default.unlinkSync(dbPath);
978
+ }
979
+ catch (e) {
980
+ console.warn('Failed to clean up temporary file:', e);
981
+ }
982
+ }
983
+ }
984
+ }
899
985
  }
900
986
  exports.SnapProcessor = SnapProcessor;
@@ -429,6 +429,8 @@ class TouchChatProcessor extends baseProcessor_1.BaseProcessor {
429
429
  tree.rootId = rootPageId;
430
430
  }
431
431
  }
432
+ // Set metadata for TouchChat files
433
+ tree.metadata.format = 'touchchat';
432
434
  return tree;
433
435
  }
434
436
  finally {
@@ -132,14 +132,78 @@ export interface AACPage {
132
132
  scanningConfig?: ScanningConfig;
133
133
  scanBlocksConfig?: any[];
134
134
  }
135
+ /**
136
+ * Generic metadata interface for AAC files
137
+ * All processors can extend this with their specific properties
138
+ */
139
+ export interface AACTreeMetadata {
140
+ format?: string;
141
+ version?: string;
142
+ locale?: string;
143
+ languages?: string[];
144
+ name?: string;
145
+ description?: string;
146
+ author?: string;
147
+ copyright?: string;
148
+ homepageUrl?: string;
149
+ url?: string;
150
+ id?: string;
151
+ defaultHomePageId?: string;
152
+ defaultKeyboardPageId?: string;
153
+ hasGlobalToolbar?: boolean;
154
+ toolbarId?: string;
155
+ dashboardId?: string;
156
+ [key: string]: any;
157
+ }
158
+ /**
159
+ * Snap-specific metadata
160
+ */
161
+ export interface SnapMetadata extends AACTreeMetadata {
162
+ format: 'snap';
163
+ dashboardId?: string;
164
+ }
165
+ /**
166
+ * GridSet-specific metadata
167
+ */
168
+ export interface GridSetMetadata extends AACTreeMetadata {
169
+ format: 'gridset';
170
+ isSmartBox?: boolean;
171
+ passwordProtected?: boolean;
172
+ pictureSearchKeys?: string[];
173
+ thumbnail?: string;
174
+ thumbnailBackground?: string;
175
+ documentationUrl?: string;
176
+ documentationSlug?: string;
177
+ appearance?: {
178
+ textAtTop?: boolean;
179
+ computerControlCellSize?: number;
180
+ };
181
+ }
182
+ /**
183
+ * Asterics-specific metadata
184
+ */
185
+ export interface AstericsGridMetadata extends AACTreeMetadata {
186
+ format: 'asterics';
187
+ hasGlobalGrid?: boolean;
188
+ globalGridId?: string;
189
+ }
190
+ /**
191
+ * TouchChat-specific metadata
192
+ */
193
+ export interface TouchChatMetadata extends AACTreeMetadata {
194
+ format: 'touchchat';
195
+ }
135
196
  export interface AACTree {
136
197
  pages: {
137
198
  [key: string]: AACPage;
138
199
  };
200
+ metadata: AACTreeMetadata;
201
+ rootId: string | null;
202
+ toolbarId: string | null;
139
203
  addPage(page: AACPage): void;
140
204
  getPage(id: string): AACPage | undefined;
141
205
  }
142
206
  export interface AACProcessor {
143
- extractTexts(filePath: string): string[];
144
- loadIntoTree(filePath: string): AACTree;
207
+ extractTexts(filePath: string | Buffer): string[];
208
+ loadIntoTree(filePath: string | Buffer): AACTree;
145
209
  }
@@ -35,7 +35,7 @@ class MetricsCalculator {
35
35
  if (!rootBoard) {
36
36
  throw new Error('No root board found in tree');
37
37
  }
38
- this.locale = rootBoard.locale || 'en';
38
+ this.locale = tree.metadata?.locale || rootBoard.locale || 'en';
39
39
  // Step 1: Build semantic/clone reference maps
40
40
  const { setRefs, setPcts } = this.buildReferenceMaps(tree);
41
41
  // Step 2: BFS traversal from root board
@@ -128,6 +128,9 @@ class MetricsCalculator {
128
128
  if (options.spellingPageId) {
129
129
  spellingPage = tree.getPage(options.spellingPageId) || null;
130
130
  }
131
+ if (!spellingPage && tree.metadata?.defaultKeyboardPageId) {
132
+ spellingPage = tree.getPage(tree.metadata.defaultKeyboardPageId) || null;
133
+ }
131
134
  if (!spellingPage) {
132
135
  // Look for pages with keyboard-like names or content
133
136
  spellingPage =
@@ -17,6 +17,18 @@ export declare class GridsetValidator extends BaseValidator {
17
17
  * Main validation method
18
18
  */
19
19
  validate(content: Buffer | Uint8Array, filename: string, filesize: number): Promise<ValidationResult>;
20
+ /**
21
+ * Check if the buffer is a zip archive
22
+ */
23
+ private isZip;
24
+ /**
25
+ * Validate a single XML file (legacy or exploded format)
26
+ */
27
+ private validateSingleXml;
28
+ /**
29
+ * Validate a ZIP archive (.gridset)
30
+ */
31
+ private validateZipArchive;
20
32
  /**
21
33
  * Validate Gridset structure
22
34
  */
@@ -22,6 +22,9 @@ var __importStar = (this && this.__importStar) || function (mod) {
22
22
  __setModuleDefault(result, mod);
23
23
  return result;
24
24
  };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
25
28
  Object.defineProperty(exports, "__esModule", { value: true });
26
29
  exports.GridsetValidator = void 0;
27
30
  /* eslint-disable @typescript-eslint/require-await */
@@ -30,6 +33,7 @@ exports.GridsetValidator = void 0;
30
33
  const fs = __importStar(require("fs"));
31
34
  const path = __importStar(require("path"));
32
35
  const xml2js = __importStar(require("xml2js"));
36
+ const adm_zip_1 = __importDefault(require("adm-zip"));
33
37
  const baseValidator_1 = require("./baseValidator");
34
38
  /**
35
39
  * Validator for Grid3/Smartbox Gridset files (.gridset, .gridsetx)
@@ -86,6 +90,27 @@ class GridsetValidator extends baseValidator_1.BaseValidator {
86
90
  });
87
91
  return this.buildResult(filename, filesize, 'gridset');
88
92
  }
93
+ const isZip = this.isZip(content);
94
+ if (isZip) {
95
+ await this.validateZipArchive(content, filename, filesize);
96
+ }
97
+ else {
98
+ await this.validateSingleXml(content, filename, filesize);
99
+ }
100
+ return this.buildResult(filename, filesize, 'gridset');
101
+ }
102
+ /**
103
+ * Check if the buffer is a zip archive
104
+ */
105
+ isZip(content) {
106
+ if (content.length < 4)
107
+ return false;
108
+ return content[0] === 0x50 && content[1] === 0x4b && content[2] === 0x03 && content[3] === 0x04;
109
+ }
110
+ /**
111
+ * Validate a single XML file (legacy or exploded format)
112
+ */
113
+ async validateSingleXml(content, filename, _filesize) {
89
114
  let xmlObj = null;
90
115
  await this.add_check('xml_parse', 'valid XML', async () => {
91
116
  try {
@@ -97,10 +122,8 @@ class GridsetValidator extends baseValidator_1.BaseValidator {
97
122
  this.err(`Failed to parse XML: ${e.message}`, true);
98
123
  }
99
124
  });
100
- if (!xmlObj) {
101
- return this.buildResult(filename, filesize, 'gridset');
102
- }
103
- // eslint-disable-next-line @typescript-eslint/require-await
125
+ if (!xmlObj)
126
+ return;
104
127
  await this.add_check('xml_structure', 'gridset root element', async () => {
105
128
  if (!xmlObj.gridset && !xmlObj.Gridset) {
106
129
  this.err('missing root gridset element', true);
@@ -110,7 +133,71 @@ class GridsetValidator extends baseValidator_1.BaseValidator {
110
133
  if (gridset) {
111
134
  await this.validateGridsetStructure(gridset, filename, content);
112
135
  }
113
- return this.buildResult(filename, filesize, 'gridset');
136
+ }
137
+ /**
138
+ * Validate a ZIP archive (.gridset)
139
+ */
140
+ async validateZipArchive(content, filename, _filesize) {
141
+ let zip;
142
+ try {
143
+ zip = new adm_zip_1.default(Buffer.from(content));
144
+ }
145
+ catch (e) {
146
+ this.err(`Failed to open ZIP archive: ${e.message}`, true);
147
+ return;
148
+ }
149
+ const entries = zip.getEntries();
150
+ // Check for gridset.xml (required)
151
+ await this.add_check('gridset_xml_presence', 'gridset.xml presence', async () => {
152
+ const gridsetEntry = entries.find((e) => e.entryName.toLowerCase() === 'gridset.xml');
153
+ if (!gridsetEntry) {
154
+ this.err('Missing gridset.xml in archive', true);
155
+ }
156
+ else {
157
+ try {
158
+ const gridsetXml = gridsetEntry.getData().toString('utf-8');
159
+ const parser = new xml2js.Parser();
160
+ const xmlObj = await parser.parseStringPromise(gridsetXml);
161
+ const gridset = xmlObj.gridset || xmlObj.Gridset;
162
+ if (!gridset) {
163
+ this.err('Invalid gridset.xml structure', true);
164
+ }
165
+ else {
166
+ await this.validateGridsetStructure(gridset, filename, Buffer.from(gridsetXml));
167
+ }
168
+ }
169
+ catch (e) {
170
+ this.err(`Failed to parse gridset.xml: ${e.message}`, true);
171
+ }
172
+ }
173
+ });
174
+ // Check for settings.xml (highly recommended/required for metadata)
175
+ await this.add_check('settings_xml_presence', 'settings.xml presence', async () => {
176
+ const settingsEntry = entries.find((e) => e.entryName.toLowerCase() === 'settings.xml');
177
+ if (!settingsEntry) {
178
+ this.warn('Missing settings.xml in archive (required for full metadata)');
179
+ }
180
+ else {
181
+ try {
182
+ const settingsXml = settingsEntry.getData().toString('utf-8');
183
+ const parser = new xml2js.Parser();
184
+ const xmlObj = await parser.parseStringPromise(settingsXml);
185
+ const settings = xmlObj.GridSetSettings || xmlObj.gridSetSettings || xmlObj.GridsetSettings;
186
+ if (!settings) {
187
+ this.warn('Invalid settings.xml structure');
188
+ }
189
+ else {
190
+ // Basic validation of settings.xml
191
+ if (!settings.StartGrid && !settings.startGrid) {
192
+ this.warn('settings.xml missing StartGrid element');
193
+ }
194
+ }
195
+ }
196
+ catch (e) {
197
+ this.warn(`Failed to parse settings.xml: ${e.message}`);
198
+ }
199
+ }
200
+ });
114
201
  }
115
202
  /**
116
203
  * Validate Gridset structure
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "A comprehensive TypeScript library for processing AAC (Augmentative and Alternative Communication) file formats with translation support",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",