@willwade/aac-processors 0.0.30 → 0.1.1

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 (94) hide show
  1. package/README.md +52 -852
  2. package/dist/browser/core/baseProcessor.js +241 -0
  3. package/dist/browser/core/stringCasing.js +179 -0
  4. package/dist/browser/core/treeStructure.js +255 -0
  5. package/dist/browser/index.browser.js +73 -0
  6. package/dist/browser/processors/applePanelsProcessor.js +582 -0
  7. package/dist/browser/processors/astericsGridProcessor.js +1509 -0
  8. package/dist/browser/processors/dotProcessor.js +221 -0
  9. package/dist/browser/processors/gridset/commands.js +962 -0
  10. package/dist/browser/processors/gridset/crypto.js +53 -0
  11. package/dist/browser/processors/gridset/password.js +49 -0
  12. package/dist/browser/processors/gridset/pluginTypes.js +277 -0
  13. package/dist/browser/processors/gridset/resolver.js +137 -0
  14. package/dist/browser/processors/gridset/symbolAlignment.js +276 -0
  15. package/dist/browser/processors/gridset/symbols.js +464 -0
  16. package/dist/browser/processors/gridsetProcessor.js +2002 -0
  17. package/dist/browser/processors/obfProcessor.js +705 -0
  18. package/dist/browser/processors/opmlProcessor.js +274 -0
  19. package/dist/browser/types/aac.js +38 -0
  20. package/dist/browser/utilities/analytics/utils/idGenerator.js +89 -0
  21. package/dist/browser/utilities/translation/translationProcessor.js +200 -0
  22. package/dist/browser/utils/io.js +95 -0
  23. package/dist/browser/validation/baseValidator.js +156 -0
  24. package/dist/browser/validation/gridsetValidator.js +356 -0
  25. package/dist/browser/validation/obfValidator.js +500 -0
  26. package/dist/browser/validation/validationTypes.js +46 -0
  27. package/dist/cli/index.js +5 -5
  28. package/dist/core/analyze.d.ts +2 -2
  29. package/dist/core/analyze.js +2 -2
  30. package/dist/core/baseProcessor.d.ts +5 -4
  31. package/dist/core/baseProcessor.js +22 -27
  32. package/dist/core/treeStructure.d.ts +5 -5
  33. package/dist/core/treeStructure.js +1 -4
  34. package/dist/index.browser.d.ts +37 -0
  35. package/dist/index.browser.js +99 -0
  36. package/dist/index.d.ts +1 -48
  37. package/dist/index.js +1 -136
  38. package/dist/index.node.d.ts +48 -0
  39. package/dist/index.node.js +152 -0
  40. package/dist/processors/applePanelsProcessor.d.ts +5 -4
  41. package/dist/processors/applePanelsProcessor.js +58 -62
  42. package/dist/processors/astericsGridProcessor.d.ts +7 -6
  43. package/dist/processors/astericsGridProcessor.js +31 -42
  44. package/dist/processors/dotProcessor.d.ts +5 -4
  45. package/dist/processors/dotProcessor.js +25 -33
  46. package/dist/processors/excelProcessor.d.ts +4 -3
  47. package/dist/processors/excelProcessor.js +6 -3
  48. package/dist/processors/gridset/crypto.d.ts +18 -0
  49. package/dist/processors/gridset/crypto.js +57 -0
  50. package/dist/processors/gridset/helpers.d.ts +1 -1
  51. package/dist/processors/gridset/helpers.js +18 -8
  52. package/dist/processors/gridset/password.d.ts +20 -3
  53. package/dist/processors/gridset/password.js +29 -12
  54. package/dist/processors/gridset/symbols.js +63 -46
  55. package/dist/processors/gridset/wordlistHelpers.d.ts +3 -3
  56. package/dist/processors/gridset/wordlistHelpers.js +21 -20
  57. package/dist/processors/gridsetProcessor.d.ts +7 -12
  58. package/dist/processors/gridsetProcessor.js +116 -77
  59. package/dist/processors/obfProcessor.d.ts +9 -7
  60. package/dist/processors/obfProcessor.js +131 -56
  61. package/dist/processors/obfsetProcessor.d.ts +5 -4
  62. package/dist/processors/obfsetProcessor.js +10 -16
  63. package/dist/processors/opmlProcessor.d.ts +5 -4
  64. package/dist/processors/opmlProcessor.js +27 -34
  65. package/dist/processors/snapProcessor.d.ts +8 -7
  66. package/dist/processors/snapProcessor.js +15 -12
  67. package/dist/processors/touchchatProcessor.d.ts +8 -7
  68. package/dist/processors/touchchatProcessor.js +22 -17
  69. package/dist/types/aac.d.ts +0 -2
  70. package/dist/types/aac.js +2 -0
  71. package/dist/utils/io.d.ts +12 -0
  72. package/dist/utils/io.js +107 -0
  73. package/dist/validation/gridsetValidator.js +10 -9
  74. package/dist/validation/snapValidator.js +28 -35
  75. package/docs/BROWSER_USAGE.md +618 -0
  76. package/docs/PAGESET_GETTING_STARTED.md +185 -0
  77. package/examples/README.md +77 -0
  78. package/examples/browser-test-server.js +81 -0
  79. package/examples/browser-test.html +331 -0
  80. package/examples/vitedemo/QUICKSTART.md +75 -0
  81. package/examples/vitedemo/README.md +157 -0
  82. package/examples/vitedemo/index.html +531 -0
  83. package/examples/vitedemo/package-lock.json +1221 -0
  84. package/examples/vitedemo/package.json +18 -0
  85. package/examples/vitedemo/src/main.ts +871 -0
  86. package/examples/vitedemo/test-files/example.dot +14 -0
  87. package/examples/vitedemo/test-files/example.grd +1 -0
  88. package/examples/vitedemo/test-files/example.gridset +0 -0
  89. package/examples/vitedemo/test-files/example.obz +0 -0
  90. package/examples/vitedemo/test-files/example.opml +18 -0
  91. package/examples/vitedemo/test-files/simple.obf +53 -0
  92. package/examples/vitedemo/tsconfig.json +24 -0
  93. package/examples/vitedemo/vite.config.ts +34 -0
  94. package/package.json +21 -4
@@ -0,0 +1,871 @@
1
+ /**
2
+ * AAC Processors Browser Demo
3
+ *
4
+ * This demo uses Vite to bundle AACProcessors for browser use.
5
+ * It tests all browser-compatible processors with real file uploads.
6
+ */
7
+
8
+ // Polyfill Buffer for browser environment
9
+ if (typeof (window as any).Buffer === 'undefined') {
10
+ // Create a proper Buffer wrapper class that extends Uint8Array
11
+ class BufferWrapper extends Uint8Array {
12
+ constructor(data: any, byteOffset?: number, length?: number) {
13
+ if (typeof data === 'number') {
14
+ // Alloc case: data is the size
15
+ super(data);
16
+ } else if (Array.isArray(data)) {
17
+ super(data);
18
+ } else if (data instanceof ArrayBuffer) {
19
+ super(data, byteOffset || 0, length);
20
+ } else if (data instanceof Uint8Array) {
21
+ super(data.buffer, data.byteOffset, data.length);
22
+ } else if (typeof data === 'string') {
23
+ const encoder = new TextEncoder();
24
+ super(encoder.encode(data));
25
+ } else {
26
+ super(0);
27
+ }
28
+ }
29
+
30
+ toString(encoding: string = 'utf8'): string {
31
+ if (encoding === 'utf8' || encoding === 'utf-8') {
32
+ const decoder = new TextDecoder('utf-8');
33
+ return decoder.decode(this);
34
+ }
35
+ throw new Error(`Buffer.toString: encoding ${encoding} not supported`);
36
+ }
37
+
38
+ static from(data: any, encoding?: string): BufferWrapper {
39
+ return new BufferWrapper(data);
40
+ }
41
+
42
+ static alloc(size: number): BufferWrapper {
43
+ return new BufferWrapper(size);
44
+ }
45
+
46
+ static allocUnsafe(size: number): BufferWrapper {
47
+ return new BufferWrapper(size);
48
+ }
49
+
50
+ static concat(list: Uint8Array[], totalLength?: number): BufferWrapper {
51
+ const result = new Uint8Array(totalLength || list.reduce((sum, arr) => sum + arr.length, 0));
52
+ let offset = 0;
53
+ for (const arr of list) {
54
+ result.set(arr, offset);
55
+ offset += arr.length;
56
+ }
57
+ return new BufferWrapper(result.buffer, result.byteOffset, result.length);
58
+ }
59
+
60
+ static isBuffer(obj: any): boolean {
61
+ return obj instanceof BufferWrapper;
62
+ }
63
+ }
64
+
65
+ (window as any).Buffer = BufferWrapper as any;
66
+ }
67
+
68
+ import {
69
+ getProcessor,
70
+ getSupportedExtensions,
71
+ DotProcessor,
72
+ OpmlProcessor,
73
+ ObfProcessor,
74
+ GridsetProcessor,
75
+ ApplePanelsProcessor,
76
+ AstericsGridProcessor,
77
+ AACTree,
78
+ AACPage,
79
+ AACButton
80
+ } from 'aac-processors';
81
+
82
+ // UI Elements
83
+ const dropArea = document.getElementById('dropArea') as HTMLElement;
84
+ const fileInput = document.getElementById('fileInput') as HTMLInputElement;
85
+ const processBtn = document.getElementById('processBtn') as HTMLButtonElement;
86
+ const runTestsBtn = document.getElementById('runTestsBtn') as HTMLButtonElement;
87
+ const clearBtn = document.getElementById('clearBtn') as HTMLButtonElement;
88
+ const fileInfo = document.getElementById('fileInfo') as HTMLElement;
89
+ const processorName = document.getElementById('processorName') as HTMLElement;
90
+ const fileDetails = document.getElementById('fileDetails') as HTMLElement;
91
+ const stats = document.getElementById('stats') as HTMLElement;
92
+ const results = document.getElementById('results') as HTMLElement;
93
+ const logPanel = document.getElementById('logPanel') as HTMLElement;
94
+ const testResults = document.getElementById('testResults') as HTMLElement;
95
+ const testList = document.getElementById('testList') as HTMLElement;
96
+ const tabButtons = document.querySelectorAll('.tab-btn') as NodeListOf<HTMLButtonElement>;
97
+ const inspectTab = document.getElementById('inspectTab') as HTMLElement;
98
+ const pagesetTab = document.getElementById('pagesetTab') as HTMLElement;
99
+ const templateSelect = document.getElementById('templateSelect') as HTMLSelectElement;
100
+ const formatSelect = document.getElementById('formatSelect') as HTMLSelectElement;
101
+ const createPagesetBtn = document.getElementById('createPagesetBtn') as HTMLButtonElement;
102
+ const previewPagesetBtn = document.getElementById('previewPagesetBtn') as HTMLButtonElement;
103
+ const convertToObfBtn = document.getElementById('convertToObfBtn') as HTMLButtonElement;
104
+ const convertToObzBtn = document.getElementById('convertToObzBtn') as HTMLButtonElement;
105
+ const conversionStatus = document.getElementById('conversionStatus') as HTMLElement;
106
+ const pagesetOutput = document.getElementById('pagesetOutput') as HTMLElement;
107
+
108
+ // State
109
+ let currentFile: File | null = null;
110
+ let currentProcessor: any = null;
111
+ let currentTree: AACTree | null = null;
112
+ let currentSourceLabel = 'pageset';
113
+
114
+ // Tabs
115
+ function setActiveTab(tabId: string) {
116
+ tabButtons.forEach((btn) => {
117
+ btn.classList.toggle('active', btn.dataset.tab === tabId);
118
+ });
119
+ inspectTab.classList.toggle('active', tabId === 'inspectTab');
120
+ pagesetTab.classList.toggle('active', tabId === 'pagesetTab');
121
+ }
122
+
123
+ tabButtons.forEach((btn) => {
124
+ btn.addEventListener('click', () => {
125
+ setActiveTab(btn.dataset.tab || 'inspectTab');
126
+ });
127
+ });
128
+
129
+ // Logging
130
+ function log(message: string, type: 'info' | 'success' | 'error' | 'warn' = 'info') {
131
+ const entry = document.createElement('div');
132
+ entry.className = `log-entry log-${type}`;
133
+ entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
134
+ logPanel.appendChild(entry);
135
+ logPanel.scrollTop = logPanel.scrollHeight;
136
+ console.log(`[${type.toUpperCase()}]`, message);
137
+ }
138
+
139
+ function setConversionStatus(message: string, state: 'success' | 'warn' | 'info' = 'info') {
140
+ conversionStatus.textContent = message;
141
+ conversionStatus.classList.remove('success', 'warn');
142
+ if (state !== 'info') {
143
+ conversionStatus.classList.add(state);
144
+ }
145
+ }
146
+
147
+ function updateConvertButtons() {
148
+ const hasTree = !!currentTree;
149
+ convertToObfBtn.disabled = !hasTree;
150
+ convertToObzBtn.disabled = !hasTree;
151
+ if (!hasTree) {
152
+ setConversionStatus('No pageset loaded yet.', 'info');
153
+ } else {
154
+ setConversionStatus(`Ready to export: ${currentSourceLabel}`, 'success');
155
+ }
156
+ }
157
+
158
+ function updateStatsForTree(tree: AACTree, textCount?: number, loadTimeMs?: number) {
159
+ const pageCount = Object.keys(tree.pages).length;
160
+ const buttonCount = Object.values(tree.pages).reduce(
161
+ (sum: number, page: AACPage) => sum + page.buttons.length,
162
+ 0
163
+ );
164
+
165
+ document.getElementById('pageCount')!.textContent = pageCount.toString();
166
+ document.getElementById('buttonCount')!.textContent = buttonCount.toString();
167
+ document.getElementById('textCount')!.textContent = (textCount ?? 0).toString();
168
+ document.getElementById('loadTime')!.textContent =
169
+ loadTimeMs !== undefined ? `${loadTimeMs.toFixed(0)}ms` : '—';
170
+ stats.style.display = 'grid';
171
+ }
172
+
173
+ function collectTextCount(tree: AACTree): number {
174
+ const texts = new Set<string>();
175
+ Object.values(tree.pages).forEach((page) => {
176
+ if (page.name) texts.add(page.name);
177
+ page.buttons.forEach((button) => {
178
+ if (button.label) texts.add(button.label);
179
+ if (button.message) texts.add(button.message);
180
+ });
181
+ });
182
+ return texts.size;
183
+ }
184
+
185
+ // Get file extension
186
+ function getFileExtension(filename: string): string {
187
+ const match = filename.toLowerCase().match(/\.\w+$/);
188
+ return match ? match[0] : '';
189
+ }
190
+
191
+ // Format file size
192
+ function formatFileSize(bytes: number): string {
193
+ if (bytes < 1024) return bytes + ' B';
194
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
195
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
196
+ }
197
+
198
+ // Handle file selection
199
+ function handleFile(file: File) {
200
+ currentFile = file;
201
+ const extension = getFileExtension(file.name);
202
+
203
+ log(`Selected file: ${file.name} (${formatFileSize(file.size)})`, 'info');
204
+
205
+ // Check if extension is supported
206
+ if (!getSupportedExtensions().includes(extension)) {
207
+ log(`Unsupported file type: ${extension}`, 'error');
208
+ processorName.textContent = '❌ Unsupported file type';
209
+ fileDetails.textContent = extension;
210
+ fileInfo.style.display = 'block';
211
+ processBtn.disabled = true;
212
+ return;
213
+ }
214
+
215
+ // Get processor
216
+ try {
217
+ currentProcessor = getProcessor(extension);
218
+ processorName.textContent = `✅ ${currentProcessor.constructor.name}`;
219
+ fileDetails.textContent = `${file.name} • ${formatFileSize(file.size)}`;
220
+ fileInfo.style.display = 'block';
221
+ processBtn.disabled = false;
222
+ currentSourceLabel = file.name;
223
+
224
+ log(`Using processor: ${currentProcessor.constructor.name}`, 'success');
225
+ } catch (error) {
226
+ log(`Error getting processor: ${(error as Error).message}`, 'error');
227
+ processBtn.disabled = true;
228
+ }
229
+ }
230
+
231
+ // Drag and drop handlers
232
+ dropArea.addEventListener('dragover', (e) => {
233
+ e.preventDefault();
234
+ dropArea.classList.add('dragover');
235
+ });
236
+
237
+ dropArea.addEventListener('dragleave', () => {
238
+ dropArea.classList.remove('dragover');
239
+ });
240
+
241
+ dropArea.addEventListener('drop', (e) => {
242
+ e.preventDefault();
243
+ dropArea.classList.remove('dragover');
244
+
245
+ const file = e.dataTransfer?.files[0];
246
+ if (file) {
247
+ fileInput.files = e.dataTransfer!.files;
248
+ handleFile(file);
249
+ }
250
+ });
251
+
252
+ dropArea.addEventListener('click', () => {
253
+ fileInput.click();
254
+ });
255
+
256
+ fileInput.addEventListener('change', (e) => {
257
+ const file = (e.target as HTMLInputElement).files?.[0];
258
+ if (file) {
259
+ handleFile(file);
260
+ }
261
+ });
262
+
263
+ // Process file
264
+ processBtn.addEventListener('click', async () => {
265
+ if (!currentFile || !currentProcessor) return;
266
+
267
+ const startTime = performance.now();
268
+ log('Processing file...', 'info');
269
+
270
+ try {
271
+ processBtn.disabled = true;
272
+ results.innerHTML = '<p style="text-align: center; padding: 40px;">⏳ Loading...</p>';
273
+
274
+ // Read file as ArrayBuffer
275
+ const arrayBuffer = await currentFile.arrayBuffer();
276
+
277
+ // Load into tree
278
+ log('Loading tree structure...', 'info');
279
+ currentTree = await currentProcessor.loadIntoTree(arrayBuffer);
280
+
281
+ const loadTime = performance.now() - startTime;
282
+ log(`Tree loaded in ${loadTime.toFixed(0)}ms`, 'success');
283
+
284
+ // Extract texts
285
+ log('Extracting texts...', 'info');
286
+ const texts = await currentProcessor.extractTexts(arrayBuffer);
287
+ log(`Extracted ${texts.length} texts`, 'success');
288
+
289
+ // Update stats
290
+ updateStatsForTree(currentTree, texts.length, loadTime);
291
+
292
+ // Display results
293
+ displayResults(currentTree);
294
+ updateConvertButtons();
295
+
296
+ log(`✅ Successfully processed ${Object.keys(currentTree.pages).length} pages`, 'success');
297
+ } catch (error) {
298
+ const errorMsg = (error as Error).message;
299
+ log(`❌ Error: ${errorMsg}`, 'error');
300
+ results.innerHTML = `<p style="color: #f48771; text-align: center; padding: 40px;">
301
+ ❌ Error: ${errorMsg}
302
+ </p>`;
303
+ } finally {
304
+ processBtn.disabled = false;
305
+ }
306
+ });
307
+
308
+ // Display results
309
+ function displayResults(tree: AACTree) {
310
+ results.innerHTML = '';
311
+
312
+ const sortedPageIds = Object.keys(tree.pages).sort((a, b) => {
313
+ // Show root page first
314
+ if (a === tree.rootId) return -1;
315
+ if (b === tree.rootId) return 1;
316
+ return a.localeCompare(b);
317
+ });
318
+
319
+ sortedPageIds.forEach((pageId) => {
320
+ const page = tree.pages[pageId];
321
+ const pageCard = document.createElement('div');
322
+ pageCard.className = 'page-card';
323
+
324
+ const pageTitle = document.createElement('div');
325
+ pageTitle.className = 'page-title';
326
+ pageTitle.textContent = `${page.name} ${pageId === tree.rootId ? '🏠' : ''}`;
327
+ pageCard.appendChild(pageTitle);
328
+
329
+ if (page.buttons.length > 0) {
330
+ const buttonGrid = document.createElement('div');
331
+ buttonGrid.className = 'button-grid';
332
+
333
+ page.buttons.forEach((button) => {
334
+ const buttonItem = document.createElement('div');
335
+ buttonItem.className = 'button-item';
336
+
337
+ const label = document.createElement('div');
338
+ label.className = 'button-label';
339
+ label.textContent = button.label || '(no label)';
340
+ buttonItem.appendChild(label);
341
+
342
+ if (button.message) {
343
+ const message = document.createElement('div');
344
+ message.className = 'button-message';
345
+ message.textContent = button.message;
346
+ buttonItem.appendChild(message);
347
+ }
348
+
349
+ const type = document.createElement('div');
350
+ type.className = 'button-type';
351
+ type.textContent = button.type;
352
+
353
+ switch (button.type) {
354
+ case 'SPEAK':
355
+ type.classList.add('type-speak');
356
+ break;
357
+ case 'NAVIGATE':
358
+ type.classList.add('type-navigate');
359
+ break;
360
+ default:
361
+ type.classList.add('type-other');
362
+ }
363
+
364
+ buttonItem.appendChild(type);
365
+
366
+ // Click handler
367
+ buttonItem.addEventListener('click', () => {
368
+ if (button.type === 'SPEAK' && button.message) {
369
+ log(`🔊 Speaking: "${button.message}"`, 'info');
370
+ if ('speechSynthesis' in window) {
371
+ const utterance = new SpeechSynthesisUtterance(button.message);
372
+ speechSynthesis.speak(utterance);
373
+ }
374
+ } else if (button.type === 'NAVIGATE' && button.targetPageId) {
375
+ const targetPage = tree.pages[button.targetPageId];
376
+ if (targetPage) {
377
+ log(`🔗 Navigating to: ${targetPage.name}`, 'info');
378
+ // Scroll to page
379
+ const targetCard = Array.from(results.querySelectorAll('.page-card')).find((card) =>
380
+ card.querySelector('.page-title')?.textContent?.includes(targetPage.name)
381
+ );
382
+ if (targetCard) {
383
+ targetCard.scrollIntoView({ behavior: 'smooth', block: 'center' });
384
+ targetCard.style.animation = 'highlight 1s';
385
+ }
386
+ }
387
+ }
388
+ });
389
+
390
+ buttonGrid.appendChild(buttonItem);
391
+ });
392
+
393
+ pageCard.appendChild(buttonGrid);
394
+ } else {
395
+ const noButtons = document.createElement('p');
396
+ noButtons.textContent = 'No buttons';
397
+ noButtons.style.color = '#999';
398
+ noButtons.style.fontSize = '12px';
399
+ pageCard.appendChild(noButtons);
400
+ }
401
+
402
+ results.appendChild(pageCard);
403
+ });
404
+ }
405
+
406
+ // Clear results
407
+ clearBtn.addEventListener('click', () => {
408
+ currentFile = null;
409
+ currentProcessor = null;
410
+ currentTree = null;
411
+ currentSourceLabel = 'pageset';
412
+ fileInput.value = '';
413
+ fileInfo.style.display = 'none';
414
+ stats.style.display = 'none';
415
+ results.innerHTML = '<p style="color: #999; text-align: center; padding: 40px;">Load a file to see its contents here</p>';
416
+ testResults.style.display = 'none';
417
+ logPanel.innerHTML = '<div class="log-entry log-info">Cleared. Ready to process files...</div>';
418
+ pagesetOutput.textContent = 'Generate or convert a pageset to preview the output JSON.';
419
+ updateConvertButtons();
420
+ });
421
+
422
+ function sanitizeFilename(name: string): string {
423
+ return name
424
+ .toLowerCase()
425
+ .replace(/[^a-z0-9]+/g, '-')
426
+ .replace(/(^-|-$)/g, '') || 'pageset';
427
+ }
428
+
429
+ function buildSampleTree(template: string): AACTree {
430
+ const tree = new AACTree();
431
+ tree.metadata = {
432
+ name: template === 'home' ? 'Home & Core Demo' : 'Starter Demo',
433
+ description: 'Generated in the AAC Processors browser demo',
434
+ locale: 'en',
435
+ };
436
+
437
+ if (template === 'home') {
438
+ const hello = new AACButton({ id: 'hello', label: 'Hello', message: 'Hello', action: { type: 'SPEAK' } });
439
+ const want = new AACButton({ id: 'want', label: 'I want', message: 'I want', action: { type: 'SPEAK' } });
440
+ const help = new AACButton({ id: 'help', label: 'Help', message: 'Help', action: { type: 'SPEAK' } });
441
+ const more = new AACButton({
442
+ id: 'more',
443
+ label: 'More',
444
+ targetPageId: 'core',
445
+ action: { type: 'NAVIGATE', targetPageId: 'core' },
446
+ });
447
+ const yes = new AACButton({ id: 'yes', label: 'Yes', message: 'Yes', action: { type: 'SPEAK' } });
448
+ const no = new AACButton({ id: 'no', label: 'No', message: 'No', action: { type: 'SPEAK' } });
449
+ const stop = new AACButton({ id: 'stop', label: 'Stop', message: 'Stop', action: { type: 'SPEAK' } });
450
+ const go = new AACButton({ id: 'go', label: 'Go', message: 'Go', action: { type: 'SPEAK' } });
451
+ const food = new AACButton({
452
+ id: 'food',
453
+ label: 'Food',
454
+ targetPageId: 'food',
455
+ action: { type: 'NAVIGATE', targetPageId: 'food' },
456
+ });
457
+
458
+ const homePage = new AACPage({
459
+ id: 'home',
460
+ name: 'Home',
461
+ buttons: [hello, want, help, more, yes, no, stop, go, food],
462
+ grid: [
463
+ [hello, want, help],
464
+ [more, yes, no],
465
+ [stop, go, food],
466
+ ],
467
+ });
468
+
469
+ const hungry = new AACButton({ id: 'hungry', label: 'Hungry', message: 'I am hungry', action: { type: 'SPEAK' } });
470
+ const drink = new AACButton({ id: 'drink', label: 'Drink', message: 'I want a drink', action: { type: 'SPEAK' } });
471
+ const snack = new AACButton({ id: 'snack', label: 'Snack', message: 'Snack', action: { type: 'SPEAK' } });
472
+ const backFood = new AACButton({
473
+ id: 'back-food',
474
+ label: 'Back',
475
+ targetPageId: 'home',
476
+ action: { type: 'NAVIGATE', targetPageId: 'home' },
477
+ });
478
+
479
+ const foodPage = new AACPage({
480
+ id: 'food',
481
+ name: 'Food',
482
+ buttons: [hungry, drink, snack, backFood],
483
+ grid: [
484
+ [hungry, drink],
485
+ [snack, backFood],
486
+ ],
487
+ });
488
+
489
+ const coreYes = new AACButton({ id: 'core-yes', label: 'Yes', message: 'Yes', action: { type: 'SPEAK' } });
490
+ const coreNo = new AACButton({ id: 'core-no', label: 'No', message: 'No', action: { type: 'SPEAK' } });
491
+ const coreStop = new AACButton({ id: 'core-stop', label: 'Stop', message: 'Stop', action: { type: 'SPEAK' } });
492
+ const coreGo = new AACButton({ id: 'core-go', label: 'Go', message: 'Go', action: { type: 'SPEAK' } });
493
+ const backCore = new AACButton({
494
+ id: 'back-core',
495
+ label: 'Back',
496
+ targetPageId: 'home',
497
+ action: { type: 'NAVIGATE', targetPageId: 'home' },
498
+ });
499
+
500
+ const corePage = new AACPage({
501
+ id: 'core',
502
+ name: 'Core Words',
503
+ buttons: [coreYes, coreNo, coreStop, coreGo, backCore],
504
+ grid: [
505
+ [coreYes, coreNo],
506
+ [coreStop, coreGo],
507
+ [backCore, null],
508
+ ],
509
+ });
510
+
511
+ tree.addPage(homePage);
512
+ tree.addPage(corePage);
513
+ tree.addPage(foodPage);
514
+ tree.rootId = 'home';
515
+ return tree;
516
+ }
517
+
518
+ const hello = new AACButton({ id: 'hello', label: 'Hello', message: 'Hello', action: { type: 'SPEAK' } });
519
+ const thanks = new AACButton({ id: 'thanks', label: 'Thanks', message: 'Thank you', action: { type: 'SPEAK' } });
520
+ const yes = new AACButton({ id: 'yes', label: 'Yes', message: 'Yes', action: { type: 'SPEAK' } });
521
+ const more = new AACButton({
522
+ id: 'more',
523
+ label: 'Feelings',
524
+ targetPageId: 'feelings',
525
+ action: { type: 'NAVIGATE', targetPageId: 'feelings' },
526
+ });
527
+
528
+ const homePage = new AACPage({
529
+ id: 'home',
530
+ name: 'Starter',
531
+ buttons: [hello, thanks, yes, more],
532
+ grid: [
533
+ [hello, thanks],
534
+ [yes, more],
535
+ ],
536
+ });
537
+
538
+ const happy = new AACButton({ id: 'happy', label: 'Happy', message: 'I feel happy', action: { type: 'SPEAK' } });
539
+ const sad = new AACButton({ id: 'sad', label: 'Sad', message: 'I feel sad', action: { type: 'SPEAK' } });
540
+ const back = new AACButton({
541
+ id: 'back',
542
+ label: 'Back',
543
+ targetPageId: 'home',
544
+ action: { type: 'NAVIGATE', targetPageId: 'home' },
545
+ });
546
+
547
+ const feelingsPage = new AACPage({
548
+ id: 'feelings',
549
+ name: 'Feelings',
550
+ buttons: [happy, sad, back],
551
+ grid: [
552
+ [happy, sad],
553
+ [back, null],
554
+ ],
555
+ });
556
+
557
+ tree.addPage(homePage);
558
+ tree.addPage(feelingsPage);
559
+ tree.rootId = 'home';
560
+ return tree;
561
+ }
562
+
563
+ function buildFallbackObfBoard(page: AACPage, metadata?: AACTree['metadata']) {
564
+ const rows = page.grid.length || 1;
565
+ const columns = page.grid.reduce((max, row) => Math.max(max, row.length), 0) || page.buttons.length;
566
+ const order: (string | null)[][] = [];
567
+ const positions = new Map<string, number>();
568
+
569
+ if (page.grid.length) {
570
+ page.grid.forEach((row, rowIndex) => {
571
+ const orderRow: (string | null)[] = [];
572
+ for (let colIndex = 0; colIndex < columns; colIndex++) {
573
+ const cell = row[colIndex] || null;
574
+ if (cell) {
575
+ const id = String(cell.id ?? '');
576
+ orderRow.push(id);
577
+ positions.set(id, rowIndex * columns + colIndex);
578
+ } else {
579
+ orderRow.push(null);
580
+ }
581
+ }
582
+ order.push(orderRow);
583
+ });
584
+ } else {
585
+ const fallbackRow = page.buttons.map((button, index) => {
586
+ const id = String(button.id ?? '');
587
+ positions.set(id, index);
588
+ return id;
589
+ });
590
+ order.push(fallbackRow);
591
+ }
592
+
593
+ return {
594
+ format: 'open-board-0.1',
595
+ id: page.id,
596
+ locale: metadata?.locale || page.locale || 'en',
597
+ name: page.name || metadata?.name || 'Board',
598
+ description_html: page.descriptionHtml || metadata?.description || '',
599
+ grid: { rows, columns, order },
600
+ buttons: page.buttons.map((button) => ({
601
+ id: button.id,
602
+ label: button.label,
603
+ vocalization: button.message || button.label,
604
+ load_board: button.targetPageId ? { path: button.targetPageId } : undefined,
605
+ box_id: positions.get(String(button.id ?? '')),
606
+ background_color: button.style?.backgroundColor,
607
+ border_color: button.style?.borderColor,
608
+ })),
609
+ };
610
+ }
611
+
612
+ async function buildObfExport(tree: AACTree, format: 'obf' | 'obz') {
613
+ const obfProcessor = new ObfProcessor();
614
+ const obfInternal = obfProcessor as ObfProcessor & {
615
+ createObfBoardFromPage?: (page: AACPage, fallbackName: string, metadata?: AACTree['metadata']) => any;
616
+ };
617
+
618
+ const boards = Object.values(tree.pages).map((page) => ({
619
+ pageId: page.id,
620
+ board: obfInternal.createObfBoardFromPage
621
+ ? obfInternal.createObfBoardFromPage(page, 'Board', tree.metadata)
622
+ : buildFallbackObfBoard(page, tree.metadata),
623
+ }));
624
+
625
+ if (format === 'obf') {
626
+ const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0];
627
+ const board =
628
+ boards.find((entry) => entry.pageId === rootPage?.id)?.board ?? boards[0]?.board ?? {};
629
+ const json = JSON.stringify(board, null, 2);
630
+ return { filename: `${sanitizeFilename(tree.metadata?.name || 'pageset')}.obf`, data: json };
631
+ }
632
+
633
+ const module = await import('jszip');
634
+ const JSZip = module.default || module;
635
+ const zip = new JSZip();
636
+ boards.forEach((entry) => {
637
+ zip.file(`${entry.pageId}.obf`, JSON.stringify(entry.board, null, 2));
638
+ });
639
+ const zipData = await zip.generateAsync({ type: 'uint8array' });
640
+ return { filename: `${sanitizeFilename(tree.metadata?.name || 'pageset')}.obz`, data: zipData };
641
+ }
642
+
643
+ function triggerDownload(data: Uint8Array | string, filename: string, mime: string) {
644
+ const blob = new Blob([data], { type: mime });
645
+ const url = URL.createObjectURL(blob);
646
+ const a = document.createElement('a');
647
+ a.href = url;
648
+ a.download = filename;
649
+ a.click();
650
+ URL.revokeObjectURL(url);
651
+ }
652
+
653
+ createPagesetBtn.addEventListener('click', async () => {
654
+ const template = templateSelect.value;
655
+ const format = formatSelect.value === 'obz' ? 'obz' : 'obf';
656
+ const tree = buildSampleTree(template);
657
+ currentTree = tree;
658
+ currentSourceLabel = `${tree.metadata?.name || 'sample pageset'}`;
659
+ updateConvertButtons();
660
+
661
+ const exportData = await buildObfExport(tree, format);
662
+ const isObf = typeof exportData.data === 'string';
663
+ triggerDownload(
664
+ exportData.data,
665
+ exportData.filename,
666
+ isObf ? 'application/json' : 'application/zip'
667
+ );
668
+
669
+ pagesetOutput.textContent = isObf
670
+ ? exportData.data
671
+ : `Generated OBZ with ${Object.keys(tree.pages).length} boards.`;
672
+
673
+ log(`Created sample pageset and exported ${exportData.filename}`, 'success');
674
+ setConversionStatus(`Exported ${exportData.filename}`, 'success');
675
+ });
676
+
677
+ previewPagesetBtn.addEventListener('click', () => {
678
+ const tree = buildSampleTree(templateSelect.value);
679
+ currentTree = tree;
680
+ currentSourceLabel = `${tree.metadata?.name || 'sample pageset'}`;
681
+ displayResults(tree);
682
+ updateStatsForTree(tree, collectTextCount(tree));
683
+ updateConvertButtons();
684
+ setActiveTab('inspectTab');
685
+ log('Previewing sample pageset in viewer', 'info');
686
+ });
687
+
688
+ convertToObfBtn.addEventListener('click', async () => {
689
+ if (!currentTree) return;
690
+ const exportData = await buildObfExport(currentTree, 'obf');
691
+ triggerDownload(exportData.data, exportData.filename, 'application/json');
692
+ pagesetOutput.textContent = exportData.data as string;
693
+ log(`Converted ${currentSourceLabel} to ${exportData.filename}`, 'success');
694
+ setConversionStatus(`Exported ${exportData.filename}`, 'success');
695
+ });
696
+
697
+ convertToObzBtn.addEventListener('click', async () => {
698
+ if (!currentTree) return;
699
+ const exportData = await buildObfExport(currentTree, 'obz');
700
+ triggerDownload(exportData.data, exportData.filename, 'application/zip');
701
+ pagesetOutput.textContent = `Generated OBZ with ${Object.keys(currentTree.pages).length} boards.`;
702
+ log(`Converted ${currentSourceLabel} to ${exportData.filename}`, 'success');
703
+ setConversionStatus(`Exported ${exportData.filename}`, 'success');
704
+ });
705
+
706
+ // Run compatibility tests
707
+ runTestsBtn.addEventListener('click', async () => {
708
+ log('Running compatibility tests...', 'info');
709
+ testResults.style.display = 'block';
710
+ testList.innerHTML = '';
711
+
712
+ const tests: { name: string; fn: () => Promise<boolean> }[] = [
713
+ {
714
+ name: 'getProcessor() factory function',
715
+ fn: async () => {
716
+ const dotProc = getProcessor('.dot');
717
+ const opmlProc = getProcessor('.opml');
718
+ const obfProc = getProcessor('.obf');
719
+ const gridsetProc = getProcessor('.gridset');
720
+ return (
721
+ dotProc instanceof DotProcessor &&
722
+ opmlProc instanceof OpmlProcessor &&
723
+ obfProc instanceof ObfProcessor &&
724
+ gridsetProc instanceof GridsetProcessor
725
+ );
726
+ }
727
+ },
728
+ {
729
+ name: 'getSupportedExtensions() returns all extensions',
730
+ fn: async () => {
731
+ const extensions = getSupportedExtensions();
732
+ const expected = ['.dot', '.opml', '.obf', '.obz', '.gridset', '.plist', '.grd'];
733
+ return expected.every((ext) => extensions.includes(ext));
734
+ }
735
+ },
736
+ {
737
+ name: 'DotProcessor instantiation',
738
+ fn: async () => {
739
+ try {
740
+ new DotProcessor();
741
+ return true;
742
+ } catch {
743
+ return false;
744
+ }
745
+ }
746
+ },
747
+ {
748
+ name: 'OpmlProcessor instantiation',
749
+ fn: async () => {
750
+ try {
751
+ new OpmlProcessor();
752
+ return true;
753
+ } catch {
754
+ return false;
755
+ }
756
+ }
757
+ },
758
+ {
759
+ name: 'ObfProcessor instantiation',
760
+ fn: async () => {
761
+ try {
762
+ new ObfProcessor();
763
+ return true;
764
+ } catch {
765
+ return false;
766
+ }
767
+ }
768
+ },
769
+ {
770
+ name: 'GridsetProcessor instantiation',
771
+ fn: async () => {
772
+ try {
773
+ new GridsetProcessor();
774
+ return true;
775
+ } catch {
776
+ return false;
777
+ }
778
+ }
779
+ },
780
+ {
781
+ name: 'ApplePanelsProcessor instantiation',
782
+ fn: async () => {
783
+ try {
784
+ new ApplePanelsProcessor();
785
+ return true;
786
+ } catch {
787
+ return false;
788
+ }
789
+ }
790
+ },
791
+ {
792
+ name: 'AstericsGridProcessor instantiation',
793
+ fn: async () => {
794
+ try {
795
+ new AstericsGridProcessor();
796
+ return true;
797
+ } catch {
798
+ return false;
799
+ }
800
+ }
801
+ },
802
+ {
803
+ name: 'Processors accept ArrayBuffer type',
804
+ fn: async () => {
805
+ try {
806
+ const proc = new DotProcessor();
807
+ const buffer = new Uint8Array([123, 125]); // Invalid but tests type acceptance
808
+ await proc.loadIntoTree(buffer); // Will fail but tests that it accepts the type
809
+ return true;
810
+ } catch {
811
+ return true; // Expected to fail with invalid data, but type was accepted
812
+ }
813
+ }
814
+ }
815
+ ];
816
+
817
+ let passed = 0;
818
+ let failed = 0;
819
+
820
+ for (const test of tests) {
821
+ const item = document.createElement('div');
822
+ item.className = 'test-item';
823
+
824
+ const status = document.createElement('div');
825
+ status.className = 'test-status test-pending';
826
+ status.textContent = '⏳';
827
+
828
+ const name = document.createElement('div');
829
+ name.className = 'test-name';
830
+ name.textContent = test.name;
831
+
832
+ item.appendChild(status);
833
+ item.appendChild(name);
834
+ testList.appendChild(item);
835
+
836
+ try {
837
+ const result = await test.fn();
838
+ if (result) {
839
+ status.className = 'test-status test-pass';
840
+ status.textContent = '✓';
841
+ passed++;
842
+ log(`✓ ${test.name}`, 'success');
843
+ } else {
844
+ status.className = 'test-status test-fail';
845
+ status.textContent = '✗';
846
+ failed++;
847
+ log(`✗ ${test.name}`, 'error');
848
+ }
849
+ } catch (error) {
850
+ status.className = 'test-status test-fail';
851
+ status.textContent = '✗';
852
+ failed++;
853
+ log(`✗ ${test.name}: ${(error as Error).message}`, 'error');
854
+ }
855
+ }
856
+
857
+ log(`Tests complete: ${passed} passed, ${failed} failed`, passed === tests.length ? 'success' : 'warn');
858
+
859
+ const summary = document.createElement('div');
860
+ summary.style.marginTop = '15px';
861
+ summary.style.paddingTop = '15px';
862
+ summary.style.borderTop = '2px solid #e0e0e0';
863
+ summary.style.fontWeight = '600';
864
+ summary.textContent = `📊 Summary: ${passed}/${tests.length} tests passed`;
865
+ testList.appendChild(summary);
866
+ });
867
+
868
+ // Log initialization
869
+ log('✅ AAC Processors Browser Demo initialized', 'success');
870
+ log('📋 Supported extensions: ' + getSupportedExtensions().join(', '), 'info');
871
+ log('💡 Drop a file or click to upload', 'info');