@willwade/aac-processors 0.0.30 → 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.
- package/README.md +52 -852
- package/dist/browser/core/baseProcessor.js +241 -0
- package/dist/browser/core/stringCasing.js +179 -0
- package/dist/browser/core/treeStructure.js +255 -0
- package/dist/browser/index.browser.js +73 -0
- package/dist/browser/processors/applePanelsProcessor.js +582 -0
- package/dist/browser/processors/astericsGridProcessor.js +1509 -0
- package/dist/browser/processors/dotProcessor.js +221 -0
- package/dist/browser/processors/gridset/commands.js +962 -0
- package/dist/browser/processors/gridset/crypto.js +53 -0
- package/dist/browser/processors/gridset/password.js +43 -0
- package/dist/browser/processors/gridset/pluginTypes.js +277 -0
- package/dist/browser/processors/gridset/resolver.js +137 -0
- package/dist/browser/processors/gridset/symbolAlignment.js +276 -0
- package/dist/browser/processors/gridset/symbols.js +421 -0
- package/dist/browser/processors/gridsetProcessor.js +2002 -0
- package/dist/browser/processors/obfProcessor.js +705 -0
- package/dist/browser/processors/opmlProcessor.js +274 -0
- package/dist/browser/types/aac.js +38 -0
- package/dist/browser/utilities/analytics/utils/idGenerator.js +89 -0
- package/dist/browser/utilities/translation/translationProcessor.js +200 -0
- package/dist/browser/utils/io.js +95 -0
- package/dist/browser/validation/baseValidator.js +156 -0
- package/dist/browser/validation/gridsetValidator.js +355 -0
- package/dist/browser/validation/obfValidator.js +500 -0
- package/dist/browser/validation/validationTypes.js +46 -0
- package/dist/cli/index.js +5 -5
- package/dist/core/analyze.d.ts +2 -2
- package/dist/core/analyze.js +2 -2
- package/dist/core/baseProcessor.d.ts +5 -4
- package/dist/core/baseProcessor.js +22 -27
- package/dist/core/treeStructure.d.ts +5 -5
- package/dist/core/treeStructure.js +1 -4
- package/dist/index.browser.d.ts +37 -0
- package/dist/index.browser.js +99 -0
- package/dist/index.d.ts +1 -48
- package/dist/index.js +1 -136
- package/dist/index.node.d.ts +48 -0
- package/dist/index.node.js +152 -0
- package/dist/processors/applePanelsProcessor.d.ts +5 -4
- package/dist/processors/applePanelsProcessor.js +58 -62
- package/dist/processors/astericsGridProcessor.d.ts +7 -6
- package/dist/processors/astericsGridProcessor.js +31 -42
- package/dist/processors/dotProcessor.d.ts +5 -4
- package/dist/processors/dotProcessor.js +25 -33
- package/dist/processors/excelProcessor.d.ts +4 -3
- package/dist/processors/excelProcessor.js +6 -3
- package/dist/processors/gridset/crypto.d.ts +18 -0
- package/dist/processors/gridset/crypto.js +57 -0
- package/dist/processors/gridset/helpers.d.ts +1 -1
- package/dist/processors/gridset/helpers.js +18 -8
- package/dist/processors/gridset/password.d.ts +20 -3
- package/dist/processors/gridset/password.js +17 -3
- package/dist/processors/gridset/wordlistHelpers.d.ts +3 -3
- package/dist/processors/gridset/wordlistHelpers.js +21 -20
- package/dist/processors/gridsetProcessor.d.ts +7 -12
- package/dist/processors/gridsetProcessor.js +116 -77
- package/dist/processors/obfProcessor.d.ts +9 -7
- package/dist/processors/obfProcessor.js +131 -56
- package/dist/processors/obfsetProcessor.d.ts +5 -4
- package/dist/processors/obfsetProcessor.js +10 -16
- package/dist/processors/opmlProcessor.d.ts +5 -4
- package/dist/processors/opmlProcessor.js +27 -34
- package/dist/processors/snapProcessor.d.ts +8 -7
- package/dist/processors/snapProcessor.js +15 -12
- package/dist/processors/touchchatProcessor.d.ts +8 -7
- package/dist/processors/touchchatProcessor.js +22 -17
- package/dist/types/aac.d.ts +0 -2
- package/dist/types/aac.js +2 -0
- package/dist/utils/io.d.ts +12 -0
- package/dist/utils/io.js +107 -0
- package/dist/validation/gridsetValidator.js +7 -7
- package/dist/validation/snapValidator.js +28 -35
- package/docs/BROWSER_USAGE.md +618 -0
- package/examples/README.md +77 -0
- package/examples/browser-test-server.js +81 -0
- package/examples/browser-test.html +331 -0
- package/examples/vitedemo/QUICKSTART.md +74 -0
- package/examples/vitedemo/README.md +157 -0
- package/examples/vitedemo/index.html +376 -0
- package/examples/vitedemo/package-lock.json +1221 -0
- package/examples/vitedemo/package.json +18 -0
- package/examples/vitedemo/src/main.ts +519 -0
- package/examples/vitedemo/test-files/example.dot +14 -0
- package/examples/vitedemo/test-files/example.grd +1 -0
- package/examples/vitedemo/test-files/example.gridset +0 -0
- package/examples/vitedemo/test-files/example.obz +0 -0
- package/examples/vitedemo/test-files/example.opml +18 -0
- package/examples/vitedemo/test-files/simple.obf +53 -0
- package/examples/vitedemo/tsconfig.json +24 -0
- package/examples/vitedemo/vite.config.ts +34 -0
- package/package.json +20 -4
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
import { BaseProcessor, } from '../core/baseProcessor';
|
|
2
|
+
import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, } from '../core/treeStructure';
|
|
3
|
+
import { generateCloneId } from '../utilities/analytics/utils/idGenerator';
|
|
4
|
+
import { extractAllButtonsForTranslation, validateTranslationResults, } from '../utilities/translation/translationProcessor';
|
|
5
|
+
import { readBinaryFromInput, readTextFromInput, writeTextToPath, encodeBase64, } from '../utils/io';
|
|
6
|
+
let JSZipModuleObf;
|
|
7
|
+
async function getJSZipObf() {
|
|
8
|
+
if (!JSZipModuleObf) {
|
|
9
|
+
try {
|
|
10
|
+
// Try ES module import first (browser/Vite)
|
|
11
|
+
const module = await import('jszip');
|
|
12
|
+
JSZipModuleObf = module.default || module;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
// Fall back to CommonJS require (Node.js)
|
|
16
|
+
try {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
18
|
+
const module = require('jszip');
|
|
19
|
+
JSZipModuleObf = module.default || module;
|
|
20
|
+
}
|
|
21
|
+
catch (err2) {
|
|
22
|
+
throw new Error('Zip handling requires JSZip in this environment.');
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!JSZipModuleObf) {
|
|
27
|
+
throw new Error('Zip handling requires JSZip in this environment.');
|
|
28
|
+
}
|
|
29
|
+
return JSZipModuleObf;
|
|
30
|
+
}
|
|
31
|
+
const OBF_FORMAT_VERSION = 'open-board-0.1';
|
|
32
|
+
/**
|
|
33
|
+
* Map OBF hidden value to AAC standard visibility
|
|
34
|
+
* OBF: true = hidden, false/undefined = visible
|
|
35
|
+
* Maps to: 'Hidden' | 'Visible' | undefined
|
|
36
|
+
*/
|
|
37
|
+
function mapObfVisibility(hidden) {
|
|
38
|
+
if (hidden === undefined) {
|
|
39
|
+
return undefined; // Default to visible
|
|
40
|
+
}
|
|
41
|
+
return hidden ? 'Hidden' : 'Visible';
|
|
42
|
+
}
|
|
43
|
+
class ObfProcessor extends BaseProcessor {
|
|
44
|
+
constructor(options) {
|
|
45
|
+
super(options);
|
|
46
|
+
this.imageCache = new Map(); // Cache for data URLs
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Extract an image from the ZIP file as a Buffer
|
|
50
|
+
*/
|
|
51
|
+
async extractImageAsBuffer(imageId, images) {
|
|
52
|
+
if (!this.zipFile || !images) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
// Find the image metadata
|
|
56
|
+
const imageData = images.find((img) => img.id === imageId);
|
|
57
|
+
if (!imageData) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
// Try to get the image file from the ZIP
|
|
61
|
+
const possiblePaths = [
|
|
62
|
+
imageData.path,
|
|
63
|
+
`images/${imageData.filename || imageId}`,
|
|
64
|
+
imageData.id,
|
|
65
|
+
].filter(Boolean);
|
|
66
|
+
for (const imagePath of possiblePaths) {
|
|
67
|
+
try {
|
|
68
|
+
const file = this.zipFile.file(imagePath);
|
|
69
|
+
if (file) {
|
|
70
|
+
const buffer = await file.async('nodebuffer');
|
|
71
|
+
return buffer;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Extract an image from the ZIP file and convert to data URL
|
|
82
|
+
*/
|
|
83
|
+
async extractImageAsDataUrl(imageId, images) {
|
|
84
|
+
// Check cache first
|
|
85
|
+
if (this.imageCache.has(imageId)) {
|
|
86
|
+
return this.imageCache.get(imageId) ?? null;
|
|
87
|
+
}
|
|
88
|
+
if (!this.zipFile || !images) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
// Find the image metadata
|
|
92
|
+
const imageData = images.find((img) => img.id === imageId);
|
|
93
|
+
if (!imageData) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
// Try to get the image file from the ZIP
|
|
97
|
+
// Images are typically stored in an 'images' folder or root
|
|
98
|
+
const possiblePaths = [
|
|
99
|
+
imageData.path, // Explicit path if provided
|
|
100
|
+
`images/${imageData.filename || imageId}`, // Standard images folder
|
|
101
|
+
imageData.id, // Just the ID
|
|
102
|
+
].filter(Boolean);
|
|
103
|
+
for (const imagePath of possiblePaths) {
|
|
104
|
+
try {
|
|
105
|
+
const file = this.zipFile.file(imagePath);
|
|
106
|
+
if (file) {
|
|
107
|
+
const buffer = await file.async('uint8array');
|
|
108
|
+
const contentType = imageData.content_type ||
|
|
109
|
+
this.getMimeTypeFromFilename(imagePath);
|
|
110
|
+
const dataUrl = `data:${contentType};base64,${encodeBase64(buffer)}`;
|
|
111
|
+
this.imageCache.set(imageId, dataUrl);
|
|
112
|
+
return dataUrl;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
catch (err) {
|
|
116
|
+
// Continue to next path
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// If image has a URL, use that as fallback
|
|
121
|
+
if (imageData.url) {
|
|
122
|
+
const url = imageData.url;
|
|
123
|
+
this.imageCache.set(imageId, url);
|
|
124
|
+
return url;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
getMimeTypeFromFilename(filename) {
|
|
129
|
+
const ext = filename.toLowerCase().split('.').pop();
|
|
130
|
+
switch (ext) {
|
|
131
|
+
case 'png':
|
|
132
|
+
return 'image/png';
|
|
133
|
+
case 'jpg':
|
|
134
|
+
case 'jpeg':
|
|
135
|
+
return 'image/jpeg';
|
|
136
|
+
case 'gif':
|
|
137
|
+
return 'image/gif';
|
|
138
|
+
case 'svg':
|
|
139
|
+
return 'image/svg+xml';
|
|
140
|
+
case 'webp':
|
|
141
|
+
return 'image/webp';
|
|
142
|
+
default:
|
|
143
|
+
return 'image/png';
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
async processBoard(boardData, _boardPath) {
|
|
147
|
+
const sourceButtons = boardData.buttons || [];
|
|
148
|
+
// Calculate page ID first (used to make button IDs unique)
|
|
149
|
+
const pageId = _boardPath && _boardPath.endsWith('.obf') && !_boardPath.includes('/')
|
|
150
|
+
? _boardPath // Zip entry - use filename to match navigation paths
|
|
151
|
+
: boardData?.id
|
|
152
|
+
? String(boardData.id)
|
|
153
|
+
: _boardPath?.split('/').pop() || '';
|
|
154
|
+
const buttons = await Promise.all(sourceButtons.map(async (btn) => {
|
|
155
|
+
const semanticAction = btn.load_board
|
|
156
|
+
? {
|
|
157
|
+
category: AACSemanticCategory.NAVIGATION,
|
|
158
|
+
intent: AACSemanticIntent.NAVIGATE_TO,
|
|
159
|
+
targetId: btn.load_board.path,
|
|
160
|
+
fallback: {
|
|
161
|
+
type: 'NAVIGATE',
|
|
162
|
+
targetPageId: btn.load_board.path,
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
: {
|
|
166
|
+
category: AACSemanticCategory.COMMUNICATION,
|
|
167
|
+
intent: AACSemanticIntent.SPEAK_TEXT,
|
|
168
|
+
text: String(btn?.vocalization || btn?.label || ''),
|
|
169
|
+
fallback: {
|
|
170
|
+
type: 'SPEAK',
|
|
171
|
+
message: String(btn?.vocalization || btn?.label || ''),
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
// Resolve image if image_id is present
|
|
175
|
+
let resolvedImage;
|
|
176
|
+
let imageBuffer;
|
|
177
|
+
if (btn.image_id && boardData.images) {
|
|
178
|
+
resolvedImage =
|
|
179
|
+
(await this.extractImageAsDataUrl(btn.image_id, boardData.images)) || undefined;
|
|
180
|
+
imageBuffer =
|
|
181
|
+
(await this.extractImageAsBuffer(btn.image_id, boardData.images)) || undefined;
|
|
182
|
+
}
|
|
183
|
+
// Build parameters object for Grid3 export compatibility
|
|
184
|
+
const buttonParameters = {};
|
|
185
|
+
if (imageBuffer) {
|
|
186
|
+
buttonParameters.imageData = imageBuffer;
|
|
187
|
+
}
|
|
188
|
+
// Store image_id for web viewers to fetch images via API
|
|
189
|
+
if (btn.image_id) {
|
|
190
|
+
buttonParameters.image_id = btn.image_id;
|
|
191
|
+
}
|
|
192
|
+
return new AACButton({
|
|
193
|
+
// Make button ID unique by combining page ID and button ID
|
|
194
|
+
id: `${pageId}::${btn?.id || ''}`,
|
|
195
|
+
label: String(btn?.label || ''),
|
|
196
|
+
message: String(btn?.vocalization || btn?.label || ''),
|
|
197
|
+
visibility: mapObfVisibility(btn.hidden),
|
|
198
|
+
style: {
|
|
199
|
+
backgroundColor: btn.background_color,
|
|
200
|
+
borderColor: btn.border_color,
|
|
201
|
+
},
|
|
202
|
+
image: resolvedImage, // Set the resolved image data URL
|
|
203
|
+
resolvedImageEntry: resolvedImage,
|
|
204
|
+
parameters: Object.keys(buttonParameters).length > 0 ? buttonParameters : undefined,
|
|
205
|
+
semanticAction,
|
|
206
|
+
targetPageId: btn.load_board?.path,
|
|
207
|
+
semantic_id: btn.semantic_id, // Extract semantic_id if present
|
|
208
|
+
});
|
|
209
|
+
}));
|
|
210
|
+
const buttonMap = new Map(buttons.map((btn) => [btn.id, btn]));
|
|
211
|
+
const page = new AACPage({
|
|
212
|
+
id: pageId, // Use the page ID we calculated earlier
|
|
213
|
+
name: String(boardData?.name || ''),
|
|
214
|
+
grid: [],
|
|
215
|
+
buttons,
|
|
216
|
+
parentId: null,
|
|
217
|
+
locale: boardData.locale,
|
|
218
|
+
descriptionHtml: boardData.description_html,
|
|
219
|
+
images: boardData.images,
|
|
220
|
+
sounds: boardData.sounds,
|
|
221
|
+
});
|
|
222
|
+
// Process grid layout if available
|
|
223
|
+
if (boardData.grid) {
|
|
224
|
+
const rows = typeof boardData.grid.rows === 'number'
|
|
225
|
+
? boardData.grid.rows
|
|
226
|
+
: boardData.grid.order?.length || 0;
|
|
227
|
+
const cols = typeof boardData.grid.columns === 'number'
|
|
228
|
+
? boardData.grid.columns
|
|
229
|
+
: boardData.grid.order
|
|
230
|
+
? boardData.grid.order.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0)
|
|
231
|
+
: 0;
|
|
232
|
+
if (rows > 0 && cols > 0) {
|
|
233
|
+
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => null));
|
|
234
|
+
if (Array.isArray(boardData.grid.order) && boardData.grid.order.length) {
|
|
235
|
+
boardData.grid.order.forEach((orderRow, rowIndex) => {
|
|
236
|
+
if (!Array.isArray(orderRow))
|
|
237
|
+
return;
|
|
238
|
+
orderRow.forEach((cellId, colIndex) => {
|
|
239
|
+
if (cellId === null || cellId === undefined)
|
|
240
|
+
return;
|
|
241
|
+
if (rowIndex >= rows || colIndex >= cols)
|
|
242
|
+
return;
|
|
243
|
+
const aacBtn = buttonMap.get(`${pageId}::${cellId}`);
|
|
244
|
+
if (aacBtn) {
|
|
245
|
+
grid[rowIndex][colIndex] = aacBtn;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
for (const btn of sourceButtons) {
|
|
252
|
+
if (typeof btn.box_id === 'number') {
|
|
253
|
+
const row = Math.floor(btn.box_id / cols);
|
|
254
|
+
const col = btn.box_id % cols;
|
|
255
|
+
if (row < rows && col < cols) {
|
|
256
|
+
const aacBtn = buttonMap.get(`${pageId}::${btn.id}`);
|
|
257
|
+
if (aacBtn) {
|
|
258
|
+
grid[row][col] = aacBtn;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
page.grid = grid;
|
|
265
|
+
// Generate clone_id for buttons in the grid
|
|
266
|
+
const semanticIds = [];
|
|
267
|
+
const cloneIds = [];
|
|
268
|
+
grid.forEach((row, rowIndex) => {
|
|
269
|
+
row.forEach((btn, colIndex) => {
|
|
270
|
+
if (btn) {
|
|
271
|
+
// Generate clone_id based on position and label
|
|
272
|
+
btn.clone_id = generateCloneId(rows, cols, rowIndex, colIndex, btn.label);
|
|
273
|
+
cloneIds.push(btn.clone_id);
|
|
274
|
+
// Track semantic_id if present
|
|
275
|
+
if (btn.semantic_id) {
|
|
276
|
+
semanticIds.push(btn.semantic_id);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
// Track IDs on the page
|
|
282
|
+
if (semanticIds.length > 0) {
|
|
283
|
+
page.semantic_ids = semanticIds;
|
|
284
|
+
}
|
|
285
|
+
if (cloneIds.length > 0) {
|
|
286
|
+
page.clone_ids = cloneIds;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return page;
|
|
291
|
+
}
|
|
292
|
+
async extractTexts(filePathOrBuffer) {
|
|
293
|
+
const tree = await this.loadIntoTree(filePathOrBuffer);
|
|
294
|
+
const texts = [];
|
|
295
|
+
for (const pageId in tree.pages) {
|
|
296
|
+
const page = tree.pages[pageId];
|
|
297
|
+
if (page.name)
|
|
298
|
+
texts.push(page.name);
|
|
299
|
+
page.buttons.forEach((btn) => {
|
|
300
|
+
if (typeof btn.label === 'string')
|
|
301
|
+
texts.push(btn.label);
|
|
302
|
+
if (typeof btn.message === 'string' && btn.message !== btn.label)
|
|
303
|
+
texts.push(btn.message);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return texts;
|
|
307
|
+
}
|
|
308
|
+
async loadIntoTree(filePathOrBuffer) {
|
|
309
|
+
// Detailed logging for debugging input
|
|
310
|
+
const bufferLength = typeof filePathOrBuffer === 'string'
|
|
311
|
+
? null
|
|
312
|
+
: readBinaryFromInput(filePathOrBuffer).byteLength;
|
|
313
|
+
console.log('[OBF] loadIntoTree called with:', {
|
|
314
|
+
type: typeof filePathOrBuffer,
|
|
315
|
+
isBuffer: typeof Buffer !== 'undefined' && Buffer.isBuffer(filePathOrBuffer),
|
|
316
|
+
value: typeof filePathOrBuffer === 'string'
|
|
317
|
+
? filePathOrBuffer
|
|
318
|
+
: `[Buffer of length ${bufferLength ?? 0}]`,
|
|
319
|
+
});
|
|
320
|
+
const tree = new AACTree();
|
|
321
|
+
// Helper: try to parse JSON OBF
|
|
322
|
+
function tryParseObfJson(data) {
|
|
323
|
+
try {
|
|
324
|
+
const str = typeof data === 'string' ? data : readTextFromInput(data);
|
|
325
|
+
// Check for empty or whitespace-only content
|
|
326
|
+
if (!str.trim()) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
const obj = JSON.parse(str);
|
|
330
|
+
if (obj && typeof obj === 'object' && 'id' in obj && 'buttons' in obj) {
|
|
331
|
+
// Validate buttons is an array
|
|
332
|
+
if (!Array.isArray(obj.buttons)) {
|
|
333
|
+
throw new Error('Invalid OBF: buttons must be an array');
|
|
334
|
+
}
|
|
335
|
+
return obj;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
catch (error) {
|
|
339
|
+
// Log parsing errors for debugging but don't throw
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
// If input is a string path and ends with .obf, treat as JSON
|
|
344
|
+
if (typeof filePathOrBuffer === 'string' && filePathOrBuffer.endsWith('.obf')) {
|
|
345
|
+
try {
|
|
346
|
+
const content = readTextFromInput(filePathOrBuffer);
|
|
347
|
+
const boardData = tryParseObfJson(content);
|
|
348
|
+
if (boardData) {
|
|
349
|
+
console.log('[OBF] Detected .obf file, parsed as JSON');
|
|
350
|
+
const page = await this.processBoard(boardData, filePathOrBuffer);
|
|
351
|
+
tree.addPage(page);
|
|
352
|
+
// Set metadata from root board
|
|
353
|
+
tree.metadata.format = 'obf';
|
|
354
|
+
tree.metadata.name = boardData.name;
|
|
355
|
+
tree.metadata.description = boardData.description_html;
|
|
356
|
+
tree.metadata.locale = boardData.locale;
|
|
357
|
+
tree.metadata.id = boardData.id;
|
|
358
|
+
if (boardData.url)
|
|
359
|
+
tree.metadata.url = boardData.url;
|
|
360
|
+
if (boardData.locale)
|
|
361
|
+
tree.metadata.languages = [boardData.locale];
|
|
362
|
+
tree.rootId = page.id;
|
|
363
|
+
return tree;
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
throw new Error('Invalid OBF JSON content');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
console.error('[OBF] Error reading .obf file:', err);
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// If input is a buffer or string that parses as OBF JSON
|
|
375
|
+
const asJson = tryParseObfJson(filePathOrBuffer);
|
|
376
|
+
if (asJson) {
|
|
377
|
+
console.log('[OBF] Detected buffer/string as OBF JSON');
|
|
378
|
+
const page = await this.processBoard(asJson, '[bufferOrString]');
|
|
379
|
+
tree.addPage(page);
|
|
380
|
+
// Set metadata from root board
|
|
381
|
+
tree.metadata.format = 'obf';
|
|
382
|
+
tree.metadata.name = asJson.name;
|
|
383
|
+
tree.metadata.description = asJson.description_html;
|
|
384
|
+
tree.metadata.locale = asJson.locale;
|
|
385
|
+
tree.metadata.id = asJson.id;
|
|
386
|
+
if (asJson.url)
|
|
387
|
+
tree.metadata.url = asJson.url;
|
|
388
|
+
if (asJson.locale) {
|
|
389
|
+
tree.metadata.languages = [asJson.locale];
|
|
390
|
+
}
|
|
391
|
+
tree.rootId = page.id;
|
|
392
|
+
return tree;
|
|
393
|
+
}
|
|
394
|
+
// Otherwise, try as ZIP (.obz). Detect likely zip signature first; throw if neither JSON nor ZIP
|
|
395
|
+
function isLikelyZip(input) {
|
|
396
|
+
if (typeof input === 'string')
|
|
397
|
+
return input.endsWith('.zip') || input.endsWith('.obz');
|
|
398
|
+
const bytes = readBinaryFromInput(input);
|
|
399
|
+
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
|
|
400
|
+
}
|
|
401
|
+
if (!isLikelyZip(filePathOrBuffer)) {
|
|
402
|
+
throw new Error('Invalid OBF content: not JSON and not ZIP');
|
|
403
|
+
}
|
|
404
|
+
const JSZip = await getJSZipObf();
|
|
405
|
+
let zip;
|
|
406
|
+
try {
|
|
407
|
+
const zipInput = readBinaryFromInput(filePathOrBuffer);
|
|
408
|
+
zip = await JSZip.loadAsync(zipInput);
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
console.error('[OBF] Error loading ZIP with JSZip:', err);
|
|
412
|
+
throw err;
|
|
413
|
+
}
|
|
414
|
+
// Store the ZIP file reference for image extraction
|
|
415
|
+
this.zipFile = zip;
|
|
416
|
+
this.imageCache.clear(); // Clear cache for new file
|
|
417
|
+
console.log('[OBF] Detected zip archive, extracting .obf files');
|
|
418
|
+
// Collect all .obf entries
|
|
419
|
+
const obfEntries = [];
|
|
420
|
+
zip.forEach((relativePath, file) => {
|
|
421
|
+
if (file.dir)
|
|
422
|
+
return;
|
|
423
|
+
if (relativePath.endsWith('.obf')) {
|
|
424
|
+
obfEntries.push({ name: relativePath, file });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
// Process each .obf entry
|
|
428
|
+
for (const entry of obfEntries) {
|
|
429
|
+
try {
|
|
430
|
+
const content = await entry.file.async('string');
|
|
431
|
+
const boardData = tryParseObfJson(content);
|
|
432
|
+
if (boardData) {
|
|
433
|
+
const page = await this.processBoard(boardData, entry.name);
|
|
434
|
+
tree.addPage(page);
|
|
435
|
+
// Set metadata if not already set (use first board as reference)
|
|
436
|
+
if (!tree.metadata.format) {
|
|
437
|
+
tree.metadata.format = 'obf';
|
|
438
|
+
tree.metadata.name = boardData.name;
|
|
439
|
+
tree.metadata.description = boardData.description_html;
|
|
440
|
+
tree.metadata.locale = boardData.locale;
|
|
441
|
+
tree.metadata.id = boardData.id;
|
|
442
|
+
if (boardData.url)
|
|
443
|
+
tree.metadata.url = boardData.url;
|
|
444
|
+
if (boardData.locale)
|
|
445
|
+
tree.metadata.languages = [boardData.locale];
|
|
446
|
+
tree.rootId = page.id;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
console.warn('[OBF] Skipped entry (not valid OBF JSON):', entry.name);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
console.warn('[OBF] Error processing entry:', entry.name, err);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return tree;
|
|
458
|
+
}
|
|
459
|
+
buildGridMetadata(page) {
|
|
460
|
+
const buttonPositions = new Map();
|
|
461
|
+
const totalRows = Array.isArray(page.grid) ? page.grid.length : 0;
|
|
462
|
+
const totalColumns = totalRows > 0
|
|
463
|
+
? page.grid.reduce((max, row) => Math.max(max, Array.isArray(row) ? row.length : 0), 0)
|
|
464
|
+
: 0;
|
|
465
|
+
if (totalRows === 0 || totalColumns === 0) {
|
|
466
|
+
if (!page.buttons.length) {
|
|
467
|
+
return { rows: 0, columns: 0, order: [], buttonPositions };
|
|
468
|
+
}
|
|
469
|
+
const fallbackRow = page.buttons.map((button, index) => {
|
|
470
|
+
const id = String(button.id ?? '');
|
|
471
|
+
buttonPositions.set(id, index);
|
|
472
|
+
return id;
|
|
473
|
+
});
|
|
474
|
+
return {
|
|
475
|
+
rows: 1,
|
|
476
|
+
columns: fallbackRow.length,
|
|
477
|
+
order: [fallbackRow],
|
|
478
|
+
buttonPositions,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const order = [];
|
|
482
|
+
for (let rowIndex = 0; rowIndex < totalRows; rowIndex++) {
|
|
483
|
+
const sourceRow = page.grid[rowIndex] || [];
|
|
484
|
+
const orderRow = [];
|
|
485
|
+
for (let colIndex = 0; colIndex < totalColumns; colIndex++) {
|
|
486
|
+
const cell = sourceRow[colIndex] || null;
|
|
487
|
+
if (cell) {
|
|
488
|
+
const id = String(cell.id ?? '');
|
|
489
|
+
orderRow.push(id);
|
|
490
|
+
buttonPositions.set(id, rowIndex * totalColumns + colIndex);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
orderRow.push(null);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
order.push(orderRow);
|
|
497
|
+
}
|
|
498
|
+
return { rows: totalRows, columns: totalColumns, order, buttonPositions };
|
|
499
|
+
}
|
|
500
|
+
createObfBoardFromPage(page, fallbackName, metadata) {
|
|
501
|
+
const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page);
|
|
502
|
+
const boardName = metadata?.name && page.id === metadata?.defaultHomePageId
|
|
503
|
+
? metadata.name
|
|
504
|
+
: page.name || fallbackName;
|
|
505
|
+
return {
|
|
506
|
+
format: OBF_FORMAT_VERSION,
|
|
507
|
+
id: page.id,
|
|
508
|
+
url: metadata?.url,
|
|
509
|
+
locale: metadata?.locale || page.locale || 'en',
|
|
510
|
+
name: boardName,
|
|
511
|
+
description_html: metadata?.description && page.id === metadata?.defaultHomePageId
|
|
512
|
+
? metadata.description
|
|
513
|
+
: page.descriptionHtml || boardName,
|
|
514
|
+
grid: {
|
|
515
|
+
rows,
|
|
516
|
+
columns,
|
|
517
|
+
order,
|
|
518
|
+
},
|
|
519
|
+
buttons: page.buttons.map((button) => ({
|
|
520
|
+
id: button.id,
|
|
521
|
+
label: button.label,
|
|
522
|
+
vocalization: button.message || button.label,
|
|
523
|
+
load_board: button.semanticAction?.intent === AACSemanticIntent.NAVIGATE_TO && button.targetPageId
|
|
524
|
+
? {
|
|
525
|
+
path: button.targetPageId,
|
|
526
|
+
}
|
|
527
|
+
: undefined,
|
|
528
|
+
background_color: button.style?.backgroundColor,
|
|
529
|
+
border_color: button.style?.borderColor,
|
|
530
|
+
box_id: buttonPositions.get(String(button.id ?? '')),
|
|
531
|
+
})),
|
|
532
|
+
images: Array.isArray(page.images) ? page.images : [],
|
|
533
|
+
sounds: Array.isArray(page.sounds) ? page.sounds : [],
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
async processTexts(filePathOrBuffer, translations, outputPath) {
|
|
537
|
+
// Load the tree, apply translations, and save to new file
|
|
538
|
+
const tree = await this.loadIntoTree(filePathOrBuffer);
|
|
539
|
+
// Apply translations to all text content
|
|
540
|
+
Object.values(tree.pages).forEach((page) => {
|
|
541
|
+
// Translate page names
|
|
542
|
+
if (page.name && translations.has(page.name)) {
|
|
543
|
+
const translatedName = translations.get(page.name);
|
|
544
|
+
if (translatedName !== undefined) {
|
|
545
|
+
page.name = translatedName;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
// Translate button labels and messages
|
|
549
|
+
page.buttons.forEach((button) => {
|
|
550
|
+
if (button.label && translations.has(button.label)) {
|
|
551
|
+
const translatedLabel = translations.get(button.label);
|
|
552
|
+
if (translatedLabel !== undefined) {
|
|
553
|
+
button.label = translatedLabel;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (button.message && translations.has(button.message)) {
|
|
557
|
+
const translatedMessage = translations.get(button.message);
|
|
558
|
+
if (translatedMessage !== undefined) {
|
|
559
|
+
button.message = translatedMessage;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
// Save the translated tree and return its content
|
|
565
|
+
await this.saveFromTree(tree, outputPath);
|
|
566
|
+
return readBinaryFromInput(outputPath);
|
|
567
|
+
}
|
|
568
|
+
async saveFromTree(tree, outputPath) {
|
|
569
|
+
if (outputPath.endsWith('.obf')) {
|
|
570
|
+
// Save as single OBF JSON file
|
|
571
|
+
const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0];
|
|
572
|
+
if (!rootPage) {
|
|
573
|
+
throw new Error('No pages to save');
|
|
574
|
+
}
|
|
575
|
+
const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata);
|
|
576
|
+
writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2));
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
// Save as OBZ (zip with multiple OBF files)
|
|
580
|
+
const JSZip = await getJSZipObf();
|
|
581
|
+
const zip = new JSZip();
|
|
582
|
+
Object.values(tree.pages).forEach((page) => {
|
|
583
|
+
const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
|
|
584
|
+
const obfContent = JSON.stringify(obfBoard, null, 2);
|
|
585
|
+
zip.file(`${page.id}.obf`, obfContent);
|
|
586
|
+
});
|
|
587
|
+
const zipBuffer = await zip.generateAsync({ type: 'uint8array' });
|
|
588
|
+
const { writeBinaryToPath } = await import('../utils/io');
|
|
589
|
+
writeBinaryToPath(outputPath, zipBuffer);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Extract strings with metadata for aac-tools-platform compatibility
|
|
594
|
+
* Uses the generic implementation from BaseProcessor
|
|
595
|
+
*/
|
|
596
|
+
async extractStringsWithMetadata(filePath) {
|
|
597
|
+
return this.extractStringsWithMetadataGeneric(filePath);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Generate translated download for aac-tools-platform compatibility
|
|
601
|
+
* Uses the generic implementation from BaseProcessor
|
|
602
|
+
*/
|
|
603
|
+
async generateTranslatedDownload(filePath, translatedStrings, sourceStrings) {
|
|
604
|
+
return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Validate OBF/OBZ file format
|
|
608
|
+
* @param filePath - Path to the file to validate
|
|
609
|
+
* @returns Promise with validation result
|
|
610
|
+
*/
|
|
611
|
+
async validate(filePath) {
|
|
612
|
+
const ObfValidator = this.getObfValidator();
|
|
613
|
+
return ObfValidator.validateFile(filePath);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Extract symbol information from an OBF/OBZ file for LLM-based translation.
|
|
617
|
+
* Returns a structured format showing which buttons have symbols and their context.
|
|
618
|
+
*
|
|
619
|
+
* This method uses shared translation utilities that work across all AAC formats.
|
|
620
|
+
*
|
|
621
|
+
* @param filePathOrBuffer - Path to OBF/OBZ file or buffer
|
|
622
|
+
* @returns Array of symbol information for LLM processing
|
|
623
|
+
*/
|
|
624
|
+
async extractSymbolsForLLM(filePathOrBuffer) {
|
|
625
|
+
const tree = await this.loadIntoTree(filePathOrBuffer);
|
|
626
|
+
// Collect all buttons from all pages
|
|
627
|
+
const allButtons = [];
|
|
628
|
+
Object.values(tree.pages).forEach((page) => {
|
|
629
|
+
page.buttons.forEach((button) => {
|
|
630
|
+
// Add page context to each button
|
|
631
|
+
button.pageId = page.id;
|
|
632
|
+
button.pageName = page.name || page.id;
|
|
633
|
+
allButtons.push(button);
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
// Use shared utility to extract buttons with translation context
|
|
637
|
+
return extractAllButtonsForTranslation(allButtons, (button) => ({
|
|
638
|
+
pageId: button.pageId,
|
|
639
|
+
pageName: button.pageName,
|
|
640
|
+
}));
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Apply LLM translations with symbol information.
|
|
644
|
+
* The LLM should provide translations with symbol attachments in the correct positions.
|
|
645
|
+
*
|
|
646
|
+
* This method uses shared translation utilities that work across all AAC formats.
|
|
647
|
+
*
|
|
648
|
+
* @param filePathOrBuffer - Path to OBF/OBZ file or buffer
|
|
649
|
+
* @param llmTranslations - Array of LLM translations with symbol info
|
|
650
|
+
* @param outputPath - Where to save the translated OBF/OBZ file
|
|
651
|
+
* @param options - Translation options (e.g., allowPartial for testing)
|
|
652
|
+
* @returns Buffer of the translated OBF/OBZ file
|
|
653
|
+
*/
|
|
654
|
+
async processLLMTranslations(filePathOrBuffer, llmTranslations, outputPath, options) {
|
|
655
|
+
const tree = await this.loadIntoTree(filePathOrBuffer);
|
|
656
|
+
// Validate translations using shared utility
|
|
657
|
+
const buttonIds = Object.values(tree.pages).flatMap((page) => page.buttons.map((b) => b.id));
|
|
658
|
+
validateTranslationResults(llmTranslations, buttonIds, options);
|
|
659
|
+
// Create a map for quick lookup
|
|
660
|
+
const translationMap = new Map(llmTranslations.map((t) => [t.buttonId, t]));
|
|
661
|
+
// Apply translations
|
|
662
|
+
Object.values(tree.pages).forEach((page) => {
|
|
663
|
+
page.buttons.forEach((button) => {
|
|
664
|
+
const translation = translationMap.get(button.id);
|
|
665
|
+
if (!translation)
|
|
666
|
+
return;
|
|
667
|
+
// Apply label translation
|
|
668
|
+
if (translation.translatedLabel) {
|
|
669
|
+
button.label = translation.translatedLabel;
|
|
670
|
+
}
|
|
671
|
+
// Apply message translation (vocalization in OBF)
|
|
672
|
+
if (translation.translatedMessage) {
|
|
673
|
+
button.message = translation.translatedMessage;
|
|
674
|
+
// Update semantic action if symbols provided
|
|
675
|
+
if (translation.symbols && translation.symbols.length > 0) {
|
|
676
|
+
if (!button.semanticAction) {
|
|
677
|
+
button.semanticAction = {
|
|
678
|
+
category: AACSemanticCategory.COMMUNICATION,
|
|
679
|
+
intent: AACSemanticIntent.SPEAK_TEXT,
|
|
680
|
+
text: translation.translatedMessage,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
button.semanticAction.richText = {
|
|
684
|
+
text: translation.translatedMessage,
|
|
685
|
+
symbols: translation.symbols,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
// Save and return
|
|
692
|
+
await this.saveFromTree(tree, outputPath);
|
|
693
|
+
return readBinaryFromInput(outputPath);
|
|
694
|
+
}
|
|
695
|
+
getObfValidator() {
|
|
696
|
+
try {
|
|
697
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-return
|
|
698
|
+
return require('../validation/obfValidator').ObfValidator;
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
throw new Error('Validation utilities are not available in this environment.');
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
export { ObfProcessor };
|