@willwade/aac-processors 0.0.29 → 0.1.0

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 (92) 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 +43 -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 +421 -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 +355 -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 +17 -3
  54. package/dist/processors/gridset/wordlistHelpers.d.ts +3 -3
  55. package/dist/processors/gridset/wordlistHelpers.js +21 -20
  56. package/dist/processors/gridsetProcessor.d.ts +7 -12
  57. package/dist/processors/gridsetProcessor.js +118 -77
  58. package/dist/processors/obfProcessor.d.ts +9 -7
  59. package/dist/processors/obfProcessor.js +131 -56
  60. package/dist/processors/obfsetProcessor.d.ts +5 -4
  61. package/dist/processors/obfsetProcessor.js +10 -16
  62. package/dist/processors/opmlProcessor.d.ts +5 -4
  63. package/dist/processors/opmlProcessor.js +27 -34
  64. package/dist/processors/snapProcessor.d.ts +8 -7
  65. package/dist/processors/snapProcessor.js +15 -12
  66. package/dist/processors/touchchatProcessor.d.ts +8 -7
  67. package/dist/processors/touchchatProcessor.js +22 -17
  68. package/dist/types/aac.d.ts +0 -2
  69. package/dist/types/aac.js +2 -0
  70. package/dist/utils/io.d.ts +12 -0
  71. package/dist/utils/io.js +107 -0
  72. package/dist/validation/gridsetValidator.js +7 -7
  73. package/dist/validation/snapValidator.js +28 -35
  74. package/docs/BROWSER_USAGE.md +618 -0
  75. package/examples/README.md +77 -0
  76. package/examples/browser-test-server.js +81 -0
  77. package/examples/browser-test.html +331 -0
  78. package/examples/vitedemo/QUICKSTART.md +74 -0
  79. package/examples/vitedemo/README.md +157 -0
  80. package/examples/vitedemo/index.html +376 -0
  81. package/examples/vitedemo/package-lock.json +1221 -0
  82. package/examples/vitedemo/package.json +18 -0
  83. package/examples/vitedemo/src/main.ts +519 -0
  84. package/examples/vitedemo/test-files/example.dot +14 -0
  85. package/examples/vitedemo/test-files/example.grd +1 -0
  86. package/examples/vitedemo/test-files/example.gridset +0 -0
  87. package/examples/vitedemo/test-files/example.obz +0 -0
  88. package/examples/vitedemo/test-files/example.opml +18 -0
  89. package/examples/vitedemo/test-files/simple.obf +53 -0
  90. package/examples/vitedemo/tsconfig.json +24 -0
  91. package/examples/vitedemo/vite.config.ts +34 -0
  92. package/package.json +20 -4
@@ -0,0 +1,274 @@
1
+ import { BaseProcessor, } from '../core/baseProcessor';
2
+ import { AACTree, AACPage, AACButton, AACSemanticIntent } from '../core/treeStructure';
3
+ // Removed unused import: FileProcessor
4
+ import { XMLParser, XMLValidator, XMLBuilder } from 'fast-xml-parser';
5
+ import { ValidationFailureError, buildValidationResultFromMessage, } from '../validation/validationTypes';
6
+ import { getBasename, readBinaryFromInput, readTextFromInput, writeBinaryToPath, writeTextToPath, encodeText, } from '../utils/io';
7
+ class OpmlProcessor extends BaseProcessor {
8
+ constructor(options) {
9
+ super(options);
10
+ }
11
+ processOutline(outline, parentId = null) {
12
+ if (!outline || typeof outline !== 'object') {
13
+ return { page: null, childPages: [] };
14
+ }
15
+ const text = outline['@_text'] ||
16
+ (outline._attributes && outline._attributes.text) ||
17
+ outline.text;
18
+ if (!text || typeof text !== 'string') {
19
+ // Skip invalid outlines
20
+ return { page: null, childPages: [] };
21
+ }
22
+ const page = new AACPage({
23
+ id: text.replace(/[^a-zA-Z0-9]/g, '_'),
24
+ name: text,
25
+ grid: [],
26
+ buttons: [],
27
+ parentId,
28
+ });
29
+ const childPages = [];
30
+ if (outline.outline) {
31
+ const children = Array.isArray(outline.outline) ? outline.outline : [outline.outline];
32
+ children.forEach((child) => {
33
+ const childText = child['@_text'] || (child._attributes && child._attributes.text) || child.text;
34
+ if (childText && typeof childText === 'string') {
35
+ const button = new AACButton({
36
+ id: `nav_${page.id}_${childText}`,
37
+ label: childText,
38
+ message: '',
39
+ targetPageId: childText.replace(/[^a-zA-Z0-9]/g, '_'),
40
+ });
41
+ page.addButton(button);
42
+ const { page: childPage, childPages: grandChildren } = this.processOutline(child, page.id);
43
+ if (childPage && childPage.id)
44
+ childPages.push(childPage, ...grandChildren);
45
+ }
46
+ });
47
+ }
48
+ if (!page || !page.id)
49
+ return { page: null, childPages: [] };
50
+ return { page, childPages };
51
+ }
52
+ async extractTexts(filePathOrBuffer) {
53
+ await Promise.resolve();
54
+ const content = readTextFromInput(filePathOrBuffer);
55
+ const parser = new XMLParser({ ignoreAttributes: false });
56
+ const data = parser.parse(content);
57
+ const texts = [];
58
+ function processNode(node) {
59
+ // Handle different attribute formats
60
+ let textValue;
61
+ if (node && node._attributes && typeof node._attributes.text === 'string') {
62
+ textValue = node._attributes.text;
63
+ }
64
+ else if (node && typeof node['@_text'] === 'string') {
65
+ textValue = node['@_text'];
66
+ }
67
+ else if (node && typeof node.text === 'string') {
68
+ textValue = node.text;
69
+ }
70
+ if (textValue) {
71
+ texts.push(textValue);
72
+ }
73
+ if (node && node.outline) {
74
+ const children = Array.isArray(node.outline) ? node.outline : [node.outline];
75
+ children.forEach(processNode);
76
+ }
77
+ }
78
+ const outlines = Array.isArray(data.opml.body.outline)
79
+ ? data.opml.body.outline
80
+ : [data.opml.body.outline];
81
+ outlines.forEach(processNode);
82
+ return texts;
83
+ }
84
+ async loadIntoTree(filePathOrBuffer) {
85
+ await Promise.resolve();
86
+ const filename = typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.opml';
87
+ const buffer = readBinaryFromInput(filePathOrBuffer);
88
+ const content = readTextFromInput(buffer);
89
+ try {
90
+ if (!content || !content.trim()) {
91
+ const validationResult = buildValidationResultFromMessage({
92
+ filename,
93
+ filesize: buffer.byteLength,
94
+ format: 'opml',
95
+ message: 'Empty OPML content',
96
+ type: 'content',
97
+ description: 'OPML content is empty',
98
+ });
99
+ throw new ValidationFailureError('Empty OPML content', validationResult);
100
+ }
101
+ // Validate XML before parsing, fast-xml-parser is permissive by default
102
+ const validationResult = XMLValidator.validate(content);
103
+ if (validationResult !== true) {
104
+ const reason = validationResult?.err?.msg || JSON.stringify(validationResult);
105
+ const structured = buildValidationResultFromMessage({
106
+ filename,
107
+ filesize: buffer.byteLength,
108
+ format: 'opml',
109
+ message: `Invalid OPML XML: ${reason}`,
110
+ type: 'xml',
111
+ description: 'OPML XML validation',
112
+ });
113
+ throw new ValidationFailureError('Invalid OPML XML', structured);
114
+ }
115
+ const parser = new XMLParser({ ignoreAttributes: false });
116
+ const data = parser.parse(content);
117
+ const tree = new AACTree();
118
+ tree.metadata.format = 'opml';
119
+ // Handle case where body.outline might not exist or be in different formats
120
+ const bodyOutline = data.opml?.body?.outline;
121
+ if (!bodyOutline) {
122
+ const structured = buildValidationResultFromMessage({
123
+ filename,
124
+ filesize: buffer.byteLength,
125
+ format: 'opml',
126
+ message: 'Missing body.outline in OPML document',
127
+ type: 'structure',
128
+ description: 'OPML outline root',
129
+ });
130
+ throw new ValidationFailureError('Invalid OPML structure', structured);
131
+ }
132
+ const outlines = Array.isArray(bodyOutline) ? bodyOutline : [bodyOutline];
133
+ let firstRootId = null;
134
+ outlines.forEach((outline) => {
135
+ const { page, childPages } = this.processOutline(outline);
136
+ if (page && page.id) {
137
+ tree.addPage(page);
138
+ if (!firstRootId)
139
+ firstRootId = page.id;
140
+ }
141
+ childPages.forEach((childPage) => {
142
+ if (childPage && childPage.id)
143
+ tree.addPage(childPage);
144
+ });
145
+ });
146
+ // Set rootId to first root page, or fallback to first page if any exist
147
+ if (firstRootId) {
148
+ tree.rootId = firstRootId;
149
+ }
150
+ else if (Object.keys(tree.pages).length > 0) {
151
+ tree.rootId = Object.keys(tree.pages)[0];
152
+ }
153
+ return tree;
154
+ }
155
+ catch (err) {
156
+ if (err instanceof ValidationFailureError) {
157
+ throw err;
158
+ }
159
+ const validationResult = buildValidationResultFromMessage({
160
+ filename,
161
+ filesize: buffer.byteLength,
162
+ format: 'opml',
163
+ message: err?.message || 'Failed to parse OPML',
164
+ type: 'parse',
165
+ description: 'Parse OPML XML',
166
+ });
167
+ throw new ValidationFailureError('Failed to load OPML file', validationResult, err);
168
+ }
169
+ }
170
+ async processTexts(filePathOrBuffer, translations, outputPath) {
171
+ await Promise.resolve();
172
+ const content = readTextFromInput(filePathOrBuffer);
173
+ let translatedContent = content;
174
+ // Apply translations to text attributes in OPML outline elements
175
+ translations.forEach((translation, originalText) => {
176
+ if (typeof originalText === 'string' && typeof translation === 'string') {
177
+ // Replace text attributes in outline elements
178
+ const textAttrRegex = new RegExp(`text="${originalText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`, 'g');
179
+ translatedContent = translatedContent.replace(textAttrRegex, `text="${translation}"`);
180
+ }
181
+ });
182
+ const resultBuffer = encodeText(translatedContent);
183
+ writeBinaryToPath(outputPath, resultBuffer);
184
+ return resultBuffer;
185
+ }
186
+ async saveFromTree(tree, outputPath) {
187
+ await Promise.resolve();
188
+ // Helper to recursively build outline nodes with cycle detection
189
+ function buildOutline(page, visited = new Set()) {
190
+ // Prevent infinite recursion by tracking visited pages
191
+ if (visited.has(page.id)) {
192
+ return {
193
+ '@_text': `${page.name || page.id} (circular reference)`,
194
+ };
195
+ }
196
+ visited.add(page.id);
197
+ const outline = {
198
+ '@_text': page.name || page.id,
199
+ };
200
+ // Find child pages (by NAVIGATE buttons)
201
+ const childOutlines = page.buttons
202
+ .filter((b) => b.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO &&
203
+ !!b.targetPageId &&
204
+ !!tree.pages[b.targetPageId])
205
+ .map((b) => {
206
+ const targetId = b.targetPageId;
207
+ if (!targetId) {
208
+ return null;
209
+ }
210
+ const targetPage = tree.pages[targetId];
211
+ if (!targetPage) {
212
+ return null;
213
+ }
214
+ return buildOutline(targetPage, new Set(visited));
215
+ })
216
+ .filter((childOutline) => childOutline !== null);
217
+ if (childOutlines.length)
218
+ outline.outline = childOutlines;
219
+ return outline;
220
+ }
221
+ // Find root pages (no parentId or not navigated to by any button)
222
+ const navigatedIds = new Set();
223
+ Object.values(tree.pages).forEach((page) => {
224
+ page.buttons.forEach((b) => {
225
+ if (b.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && b.targetPageId)
226
+ navigatedIds.add(b.targetPageId);
227
+ });
228
+ });
229
+ let rootPages = Object.values(tree.pages).filter((page) => !navigatedIds.has(page.id));
230
+ // If no rootPages, fall back to tree.rootId
231
+ const treeRootId = tree.rootId;
232
+ if ((!rootPages || rootPages.length === 0) && treeRootId && tree.pages[treeRootId]) {
233
+ rootPages = [tree.pages[treeRootId]];
234
+ }
235
+ else if (treeRootId) {
236
+ rootPages = rootPages.sort((a, b) => a.id === treeRootId ? -1 : b.id === treeRootId ? 1 : 0);
237
+ }
238
+ // Build outlines
239
+ const outlines = rootPages.map((page) => buildOutline(page));
240
+ // Compose OPML document
241
+ const opmlObj = {
242
+ opml: {
243
+ '@_version': '2.0',
244
+ head: { title: 'Exported OPML' },
245
+ body: { outline: outlines },
246
+ },
247
+ };
248
+ // Convert to XML
249
+ const builder = new XMLBuilder({
250
+ ignoreAttributes: false,
251
+ format: true,
252
+ indentBy: ' ',
253
+ suppressEmptyNode: false,
254
+ attributeNamePrefix: '@_',
255
+ });
256
+ const xml = '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build(opmlObj);
257
+ writeTextToPath(outputPath, xml);
258
+ }
259
+ /**
260
+ * Extract strings with metadata for aac-tools-platform compatibility
261
+ * Uses the generic implementation from BaseProcessor
262
+ */
263
+ extractStringsWithMetadata(filePath) {
264
+ return this.extractStringsWithMetadataGeneric(filePath);
265
+ }
266
+ /**
267
+ * Generate translated download for aac-tools-platform compatibility
268
+ * Uses the generic implementation from BaseProcessor
269
+ */
270
+ generateTranslatedDownload(filePath, translatedStrings, sourceStrings) {
271
+ return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings);
272
+ }
273
+ }
274
+ export { OpmlProcessor };
@@ -0,0 +1,38 @@
1
+ // Note: AACSemanticAction is defined in core/treeStructure.ts to avoid circular dependency
2
+ // Import it directly if needed: import { AACSemanticAction } from '../core/treeStructure';
3
+ /**
4
+ * Scanning selection methods for switch access
5
+ * Determines how the scanning advances through items
6
+ */
7
+ export var ScanningSelectionMethod;
8
+ (function (ScanningSelectionMethod) {
9
+ /** Automatically advance through items at timed intervals (1 Switch) */
10
+ ScanningSelectionMethod["AutoScan"] = "AutoScan";
11
+ /** Automatic scanning with overscan (two-stage scanning) */
12
+ ScanningSelectionMethod["AutoScanWithOverscan"] = "AutoScanWithOverscan";
13
+ /** Hold switch to advance, release to select */
14
+ ScanningSelectionMethod["HoldToAdvance"] = "HoldToAdvance";
15
+ /** Hold to advance with overscan */
16
+ ScanningSelectionMethod["HoldToAdvanceWithOverscan"] = "HoldToAdvanceWithOverscan";
17
+ /** Tap switch to advance, tap again to select (Automatic) */
18
+ ScanningSelectionMethod["TapToAdvance"] = "TapToAdvance";
19
+ /** Tap switch to advance, another switch to select (2 Switch Step Scan) */
20
+ ScanningSelectionMethod["StepScan2Switch"] = "StepScan2Switch";
21
+ /** Tap switch 1 to advance, tap switch 1 again to select (1 Switch Step Scan) */
22
+ ScanningSelectionMethod["StepScan1Switch"] = "StepScan1Switch";
23
+ })(ScanningSelectionMethod || (ScanningSelectionMethod = {}));
24
+ /**
25
+ * Cell scanning order patterns
26
+ * Determines the sequence in which cells are highlighted
27
+ */
28
+ export var CellScanningOrder;
29
+ (function (CellScanningOrder) {
30
+ /** Simple linear scan across rows (left-to-right, top-to-bottom) */
31
+ CellScanningOrder["SimpleScan"] = "SimpleScan";
32
+ /** Simple linear scan down columns (top-to-bottom, left-to-right) */
33
+ CellScanningOrder["SimpleScanColumnsFirst"] = "SimpleScanColumnsFirst";
34
+ /** Row-group scanning: highlight rows first, then cells within selected row */
35
+ CellScanningOrder["RowColumnScan"] = "RowColumnScan";
36
+ /** Column-group scanning: highlight columns first, then cells within selected column */
37
+ CellScanningOrder["ColumnRowScan"] = "ColumnRowScan";
38
+ })(CellScanningOrder || (CellScanningOrder = {}));
@@ -0,0 +1,89 @@
1
+ /**
2
+ * ID Generator Utility for AAC Metrics
3
+ *
4
+ * Generates clone_id values based on grid location and button label.
5
+ * Clone IDs help identify buttons that appear in the same location
6
+ * across different boards in an AAC system.
7
+ */
8
+ /**
9
+ * Normalize a label for use in clone_id generation
10
+ * Converts to lowercase, removes apostrophes, trims whitespace
11
+ *
12
+ * @param label - The button label to normalize
13
+ * @returns Normalized label string
14
+ */
15
+ export function normalizeLabelForCloneId(label) {
16
+ return label
17
+ .toLowerCase()
18
+ .replace(/['']/g, '') // Remove apostrophes
19
+ .replace(/\s+/g, '_') // Replace spaces with underscores
20
+ .trim();
21
+ }
22
+ /**
23
+ * Generate a clone_id based on grid location and button label
24
+ *
25
+ * Clone ID format: "{rows}x{cols}-{row}.{col}-{label_normalized}"
26
+ * Example: "6x4-2.3-more" for button "more" at row 2, col 3 in a 6x4 grid
27
+ *
28
+ * @param rows - Total number of rows in the grid
29
+ * @param cols - Total number of columns in the grid
30
+ * @param row - Zero-based row index of the button
31
+ * @param col - Zero-based column index of the button
32
+ * @param label - The button label
33
+ * @returns A clone_id string
34
+ */
35
+ export function generateCloneId(rows, cols, row, col, label) {
36
+ const normalizedLabel = normalizeLabelForCloneId(label);
37
+ return `${rows}x${cols}-${row}.${col}-${normalizedLabel}`;
38
+ }
39
+ /**
40
+ * Generate a semantic_id based on button content
41
+ *
42
+ * Semantic IDs identify buttons with the same semantic meaning across boards.
43
+ * This is a fallback for formats that don't have explicit semantic IDs.
44
+ * Based on hash of message + label
45
+ *
46
+ * @param message - The button message/vocalization
47
+ * @param label - The button label
48
+ * @returns A semantic_id string (hash-based)
49
+ */
50
+ export function generateSemanticId(message, label) {
51
+ const content = `${message || ''}::${label || ''}`;
52
+ // Simple hash function (djb2 algorithm)
53
+ let hash = 5381;
54
+ for (let i = 0; i < content.length; i++) {
55
+ hash = (hash * 33) ^ content.charCodeAt(i);
56
+ }
57
+ // Convert to positive hex string
58
+ return `semantic_${(hash >>> 0).toString(16)}`;
59
+ }
60
+ /**
61
+ * Extract all semantic_ids from a page's buttons
62
+ *
63
+ * @param buttons - Array of buttons to scan
64
+ * @returns Array of unique semantic_id strings
65
+ */
66
+ export function extractSemanticIds(buttons) {
67
+ const ids = new Set();
68
+ for (const button of buttons) {
69
+ if (button.semantic_id) {
70
+ ids.add(button.semantic_id);
71
+ }
72
+ }
73
+ return Array.from(ids);
74
+ }
75
+ /**
76
+ * Extract all clone_ids from a page's buttons
77
+ *
78
+ * @param buttons - Array of buttons to scan
79
+ * @returns Array of unique clone_id strings
80
+ */
81
+ export function extractCloneIds(buttons) {
82
+ const ids = new Set();
83
+ for (const button of buttons) {
84
+ if (button.clone_id) {
85
+ ids.add(button.clone_id);
86
+ }
87
+ }
88
+ return Array.from(ids);
89
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * LLM-Based Translation with Symbol Preservation
3
+ *
4
+ * This module provides utilities for translating AAC files while preserving
5
+ * symbol-to-word associations across different formats (gridset, OBF, Snap, etc.).
6
+ *
7
+ * The key insight: Different AAC formats have different internal structures,
8
+ * but they all share common concepts:
9
+ * - Buttons with labels and messages
10
+ * - Symbols attached to specific words
11
+ * - Need to preserve symbol positions during translation
12
+ *
13
+ * This module provides a format-agnostic way to:
14
+ * 1. Extract symbol information for LLM processing
15
+ * 2. Apply LLM translations with preserved symbols
16
+ *
17
+ * Usage:
18
+ * 1. Processor extracts buttons and calls extractSymbolsForLLM()
19
+ * 2. LLM translates and returns aligned symbols
20
+ * 3. Processor calls processLLMTranslations() to apply results
21
+ */
22
+ /**
23
+ * Extract symbols from a button for LLM-based translation.
24
+ *
25
+ * This is a format-agnostic helper that processors can use to normalize
26
+ * their button data into a common format for LLM processing.
27
+ *
28
+ * @param buttonId - Unique identifier for the button
29
+ * @param label - Button label text
30
+ * @param message - Button message/speak text
31
+ * @param symbols - Array of symbols from the button
32
+ * @param context - Optional page context
33
+ * @returns Normalized button data for translation
34
+ */
35
+ export function normalizeButtonForTranslation(buttonId, label, message, symbols, context, grammar) {
36
+ return {
37
+ buttonId,
38
+ label,
39
+ message,
40
+ textToTranslate: message || label, // Translate message if present, otherwise label
41
+ symbols,
42
+ grammar,
43
+ ...context,
44
+ };
45
+ }
46
+ /**
47
+ * Extract symbols from various button formats.
48
+ *
49
+ * This helper handles different ways symbols might be stored in button data:
50
+ * - semanticAction.richText.symbols (gridset format)
51
+ * - symbolLibrary + symbolPath fields
52
+ * - image field with [library]path format
53
+ *
54
+ * @param button - Button object from any AAC format
55
+ * @returns Array of symbol info, or undefined if no symbols
56
+ */
57
+ export function extractSymbolsFromButton(button) {
58
+ const symbols = [];
59
+ // Method 1: Check for semanticAction.richText.symbols (gridset format)
60
+ if (button.semanticAction?.richText?.symbols) {
61
+ const richTextSymbols = button.semanticAction.richText.symbols;
62
+ if (Array.isArray(richTextSymbols) && richTextSymbols.length > 0) {
63
+ symbols.push(...richTextSymbols);
64
+ return symbols;
65
+ }
66
+ }
67
+ // Determine the text to attach symbol to
68
+ const text = button.label || button.message || '';
69
+ if (!text) {
70
+ return undefined;
71
+ }
72
+ // Method 2: Check for symbolLibrary + symbolPath fields
73
+ if (button.symbolLibrary && button.symbolPath) {
74
+ symbols.push({
75
+ text,
76
+ image: `[${button.symbolLibrary}]${button.symbolPath}`,
77
+ symbolLibrary: button.symbolLibrary,
78
+ symbolPath: button.symbolPath,
79
+ });
80
+ return symbols;
81
+ }
82
+ // Method 3: Check if image field contains a symbol reference
83
+ if (button.image && typeof button.image === 'string' && button.image.startsWith('[')) {
84
+ symbols.push({
85
+ text,
86
+ image: button.image,
87
+ });
88
+ return symbols;
89
+ }
90
+ // No symbols found
91
+ return undefined;
92
+ }
93
+ /**
94
+ * Extract all buttons from a file for LLM translation.
95
+ *
96
+ * This is a convenience method that processors can use to extract all
97
+ * translatable buttons with their symbols in a format-agnostic way.
98
+ *
99
+ * @param buttons - Array of button objects from any AAC format
100
+ * @param contextFn - Optional function to provide page context for each button
101
+ * @returns Array of normalized button data ready for LLM translation
102
+ */
103
+ export function extractAllButtonsForTranslation(buttons, contextFn) {
104
+ const results = [];
105
+ for (const button of buttons) {
106
+ if (!button)
107
+ continue;
108
+ const buttonId = (button.id || button.buttonId || `button_${results.length}`);
109
+ const label = (button.label || '');
110
+ const message = (button.message || '');
111
+ const symbols = extractSymbolsFromButton(button);
112
+ // Only include buttons that have text to translate
113
+ if (!label && !message)
114
+ continue;
115
+ const context = contextFn ? contextFn(button) : undefined;
116
+ const grammar = button.parameters?.grammar || undefined;
117
+ results.push(normalizeButtonForTranslation(buttonId, label, message, symbols || [], context, grammar));
118
+ }
119
+ return results;
120
+ }
121
+ /**
122
+ * Create a prompt for LLM translation with symbol preservation.
123
+ *
124
+ * This generates a structured prompt that instructs the LLM to translate
125
+ * while preserving symbol-to-word associations.
126
+ *
127
+ * @param buttons - Buttons to translate
128
+ * @param targetLanguage - Target language for translation
129
+ * @returns Prompt string for LLM
130
+ */
131
+ export function createTranslationPrompt(buttons, targetLanguage) {
132
+ const buttonsData = JSON.stringify(buttons, null, 2);
133
+ return `You are a translation assistant for AAC (Augmentative and Alternative Communication) systems.
134
+
135
+ Your task is to translate the following buttons to ${targetLanguage} while preserving symbol associations.
136
+
137
+ Each button has:
138
+ - label: The text shown on the button
139
+ - message: The text spoken when the button is activated
140
+ - textToTranslate: The actual text to translate (usually the message)
141
+ - symbols: Visual symbols attached to specific words
142
+ - grammar: Grammatical context (e.g., pos: Part of Speech, person, number)
143
+
144
+ IMPORTANT: After translation, you MUST reattach symbols to the correct translated words based on MEANING, not position.
145
+
146
+ Example:
147
+ - Original: "I want apple" with apple symbol on "apple"
148
+ - Spanish: "Yo quiero manzana" with apple symbol on "manzana" (NOT "Yo" or "quiero")
149
+ - French: "Je veux une pomme" with apple symbol on "pomme"
150
+
151
+ The symbols array should contain the translated word that each symbol should be attached to.
152
+
153
+ Buttons to translate:
154
+ ${buttonsData}
155
+
156
+ Return ONLY a JSON array with this exact structure:
157
+ [
158
+ {
159
+ "buttonId": "...",
160
+ "translatedLabel": "...",
161
+ "translatedMessage": "...",
162
+ "symbols": [
163
+ {"text": "translated_word", "image": "[library]path"}
164
+ ]
165
+ }
166
+ ]
167
+
168
+ Ensure all symbol image references are preserved exactly as provided.`;
169
+ }
170
+ /**
171
+ * Validate LLM translation results before applying.
172
+ *
173
+ * @param translations - LLM translation results
174
+ * @param originalButtonIds - Expected button IDs (optional, for validation)
175
+ * @param options - Validation options
176
+ * @throws Error if validation fails
177
+ */
178
+ export function validateTranslationResults(translations, originalButtonIds, options) {
179
+ if (!Array.isArray(translations)) {
180
+ throw new Error('Translation results must be an array');
181
+ }
182
+ const translatedIds = new Set(translations.map((t) => t.buttonId));
183
+ // Check that all original buttons have translations (unless partial is allowed)
184
+ if (originalButtonIds && !options?.allowPartial) {
185
+ for (const id of originalButtonIds) {
186
+ if (!translatedIds.has(id)) {
187
+ throw new Error(`Missing translation for button: ${id}`);
188
+ }
189
+ }
190
+ }
191
+ // Check each translation has required fields
192
+ for (const trans of translations) {
193
+ if (!trans.buttonId) {
194
+ throw new Error('Translation missing buttonId');
195
+ }
196
+ if (!trans.translatedMessage && !trans.translatedLabel) {
197
+ throw new Error(`Translation for ${trans.buttonId} has no translated text`);
198
+ }
199
+ }
200
+ }