@willwade/aac-processors 0.2.20 → 0.3.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 CHANGED
@@ -65,6 +65,7 @@ const tree = await processor.loadIntoTree(gridsetUint8Array);
65
65
  - DOT (Graphviz)
66
66
  - Apple Panels (macOS plist)
67
67
  - Asterics Grid
68
+ - GoTalk NOW (.gtbz)
68
69
  - Excel export
69
70
 
70
71
  ## Wrangle strings workflow
@@ -28,6 +28,7 @@ export { SnapProcessor } from './processors/snapProcessor';
28
28
  export { TouchChatProcessor } from './processors/touchchatProcessor';
29
29
  export { ApplePanelsProcessor } from './processors/applePanelsProcessor';
30
30
  export { AstericsGridProcessor } from './processors/astericsGridProcessor';
31
+ export { GotalkNowProcessor } from './processors/gotalkNowProcessor';
31
32
  // ===================================================================
32
33
  // UTILITY FUNCTIONS
33
34
  // ===================================================================
@@ -41,6 +42,7 @@ import { SnapProcessor } from './processors/snapProcessor';
41
42
  import { TouchChatProcessor } from './processors/touchchatProcessor';
42
43
  import { ApplePanelsProcessor } from './processors/applePanelsProcessor';
43
44
  import { AstericsGridProcessor } from './processors/astericsGridProcessor';
45
+ import { GotalkNowProcessor } from './processors/gotalkNowProcessor';
44
46
  export { configureSqlJs } from './utils/sqlite';
45
47
  /**
46
48
  * Factory function to get the appropriate processor for a file extension
@@ -71,6 +73,8 @@ export function getProcessor(filePathOrExtension, options) {
71
73
  return new ApplePanelsProcessor(options);
72
74
  case '.grd':
73
75
  return new AstericsGridProcessor(options);
76
+ case '.gtbz':
77
+ return new GotalkNowProcessor(options);
74
78
  default:
75
79
  throw new Error(`Unsupported file extension: ${extension}`);
76
80
  }
@@ -80,7 +84,19 @@ export function getProcessor(filePathOrExtension, options) {
80
84
  * @returns Array of supported file extensions
81
85
  */
82
86
  export function getSupportedExtensions() {
83
- return ['.dot', '.opml', '.obf', '.obz', '.gridset', '.spb', '.sps', '.ce', '.plist', '.grd'];
87
+ return [
88
+ '.dot',
89
+ '.opml',
90
+ '.obf',
91
+ '.obz',
92
+ '.gridset',
93
+ '.spb',
94
+ '.sps',
95
+ '.ce',
96
+ '.plist',
97
+ '.grd',
98
+ '.gtbz',
99
+ ];
84
100
  }
85
101
  /**
86
102
  * Check if a file extension is supported
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Helpers for decoding GoTalk NOW's NSKeyedArchiver-encoded values.
3
+ *
4
+ * GoTalk NOW stores UIColor and UIFont objects inside `<data>` tags as
5
+ * binary property lists (NSKeyedArchiver). Rather than pulling in a full
6
+ * bplist parser, we exploit the fact that the archived objects embed
7
+ * human-readable ASCII representations of the component values, e.g. an
8
+ * NSRGB colour appears as "0.908 0.355 0.522" and a font name appears as
9
+ * "Futura-Medium".
10
+ */
11
+ function bufferToLatinString(buffer) {
12
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(buffer)) {
13
+ return buffer.toString('latin1');
14
+ }
15
+ let out = '';
16
+ for (let i = 0; i < buffer.length; i++)
17
+ out += String.fromCharCode(buffer[i]);
18
+ return out;
19
+ }
20
+ function clampUnit(v) {
21
+ if (Number.isNaN(v))
22
+ return 0;
23
+ return Math.max(0, Math.min(1, v));
24
+ }
25
+ function toHexChannel(v) {
26
+ return Math.round(clampUnit(v) * 255)
27
+ .toString(16)
28
+ .padStart(2, '0');
29
+ }
30
+ /** Convert a 0-1 float triple to an #rrggbb hex string. */
31
+ function rgbToHex(r, g, b) {
32
+ return `#${toHexChannel(r)}${toHexChannel(g)}${toHexChannel(b)}`;
33
+ }
34
+ /**
35
+ * Decode an NSKeyedArchiver-encoded UIColor into a #rrggbb hex string.
36
+ *
37
+ * NSRGB / NSCalibratedRGB colours store a human-readable "R G B [A]" string
38
+ * (e.g. "0.908 0.355 0.522") inside the archive — this is the only encoding we
39
+ * can reliably read without a full binary-plist parser. NSWhite and
40
+ * UISystemColor values store their components as raw IEEE-754 floats, so we
41
+ * return undefined for those rather than risk an incorrect colour.
42
+ */
43
+ export function decodeNsColor(buffer) {
44
+ if (!buffer)
45
+ return undefined;
46
+ const s = bufferToLatinString(buffer);
47
+ // Require decimal points to avoid matching bplist header bytes (e.g. "00").
48
+ // Accept 3 or 4 components (RGB or RGBA).
49
+ const rgbMatch = s.match(/(\d+\.\d+)\s+(\d+\.\d+)\s+(\d+\.\d+)(?:\s+(\d+\.\d+))?/);
50
+ if (rgbMatch) {
51
+ const [, r, g, b] = rgbMatch;
52
+ return rgbToHex(parseFloat(r), parseFloat(g), parseFloat(b));
53
+ }
54
+ return undefined;
55
+ }
56
+ const FONT_KEYWORDS = new Set([
57
+ 'UIFont',
58
+ 'UIFontName',
59
+ 'UIFontPointSize',
60
+ 'UIFontDescriptor',
61
+ 'UIFontTraits',
62
+ 'UIFontSystemFont',
63
+ 'UIFontDescriptorAttributes',
64
+ 'UIFontSystemFontName',
65
+ 'NSFont',
66
+ 'NSFontNameAttribute',
67
+ 'NSFontSizeAttribute',
68
+ 'NSName',
69
+ 'NSSize',
70
+ 'NSMutableDictionary',
71
+ 'NSDictionary',
72
+ 'NSObject',
73
+ 'NSKeyedArchiver',
74
+ 'NSColor',
75
+ 'NSColorSpace',
76
+ 'VUIFont',
77
+ 'XNSObject',
78
+ 'classname',
79
+ 'classhints',
80
+ 'classes',
81
+ 'objects',
82
+ 'version',
83
+ 'archiver',
84
+ 'root',
85
+ ]);
86
+ /**
87
+ * Decode an NSKeyedArchiver-encoded UIFont into its family name.
88
+ * Font size is encoded as a raw IEEE-754 float and is not extracted.
89
+ */
90
+ export function decodeNsFont(buffer) {
91
+ if (!buffer)
92
+ return {};
93
+ const s = bufferToLatinString(buffer);
94
+ const tokens = s.match(/[A-Za-z][A-Za-z0-9-]{2,}/g) || [];
95
+ const fontFamily = tokens.find((t) => !FONT_KEYWORDS.has(t) &&
96
+ !t.startsWith('UI') &&
97
+ !t.startsWith('NS') &&
98
+ !t.startsWith('CF') &&
99
+ !t.startsWith('GT'));
100
+ return fontFamily ? { fontFamily } : {};
101
+ }
@@ -0,0 +1,463 @@
1
+ /**
2
+ * GoTalk NOW Processor
3
+ *
4
+ * Reads, translates, and converts Attainment Company's GoTalk NOW
5
+ * communication-board files (`.gtbz`).
6
+ *
7
+ * A `.gtbz` file is a ZIP archive containing Apple property lists:
8
+ * - BookInfo.plist – format version (GoTalkBookFormatVersion)
9
+ * - PageData.plist – pages dict keyed by page id (positive = content,
10
+ * negative = system/express-bar pages)
11
+ * - PageOrder.plist – ordered array of page ids
12
+ * - PageHistory.plist – navigation history (usually empty)
13
+ * - SettingsWriteOut.plist– app-level settings (TTS, scanning, …)
14
+ * - IconSource-<id>.png – page icons
15
+ * - Source-<page>-<btn>-<guid>-…png – button images
16
+ * - <page>-<btn>.caf – audio recordings (Core Audio Format)
17
+ * - Backup-<n>.zip – rolling backups
18
+ * - REQUIRESREGEN – marker file
19
+ *
20
+ * Colours and fonts in PageData are NSKeyedArchiver-encoded binary plists
21
+ * carried inside `<data>` tags. They are treated as opaque blobs and preserved
22
+ * verbatim so that only text fields are touched during translation.
23
+ */
24
+ import { BaseProcessor, } from '../core/baseProcessor';
25
+ import { AACTree, AACPage, AACButton, AACSemanticCategory, AACSemanticIntent, } from '../core/treeStructure';
26
+ import plist from 'plist';
27
+ import { ValidationFailureError, buildValidationResultFromMessage, } from '../validation/validationTypes';
28
+ import { getBasename } from '../utils/io';
29
+ import { decodeNsColor, decodeNsFont } from './gotalkNow/nsKeyed';
30
+ // ---------------------------------------------------------------------------
31
+ // GoTalk NOW button type → semantic-action mapping
32
+ // ---------------------------------------------------------------------------
33
+ function buildSemanticAction(rawButton, pageId, buttonIndex) {
34
+ const buttonType = rawButton.ButtonType || 'TTS';
35
+ const actionData = rawButton.ActionData || {};
36
+ const label = rawButton.ButtonText ?? '';
37
+ const ttsText = actionData.TTSText;
38
+ switch (buttonType) {
39
+ case 'Jump': {
40
+ const jumpTo = actionData.JumpTo;
41
+ const target = jumpTo !== undefined ? String(jumpTo) : undefined;
42
+ return {
43
+ semanticAction: {
44
+ category: AACSemanticCategory.NAVIGATION,
45
+ intent: AACSemanticIntent.NAVIGATE_TO,
46
+ targetId: target,
47
+ platformData: {
48
+ gotalkNow: { buttonType: 'Jump', jumpTo },
49
+ },
50
+ fallback: { type: 'NAVIGATE', targetPageId: target },
51
+ },
52
+ message: '',
53
+ targetPageId: target,
54
+ };
55
+ }
56
+ case 'Audio': {
57
+ // Audio buttons play a recorded .caf file named <pageId>-<buttonIndex>.caf
58
+ const audioLocation = `${pageId}-${buttonIndex}.caf`;
59
+ return {
60
+ semanticAction: {
61
+ category: AACSemanticCategory.MEDIA,
62
+ intent: AACSemanticIntent.PLAY_SOUND,
63
+ platformData: {
64
+ gotalkNow: { buttonType: 'Audio', audioLocation },
65
+ },
66
+ fallback: { type: 'ACTION' },
67
+ },
68
+ message: label,
69
+ };
70
+ }
71
+ case 'Express':
72
+ case 'Bookmark':
73
+ case 'URL':
74
+ case 'Video': {
75
+ return {
76
+ semanticAction: {
77
+ category: AACSemanticCategory.SYSTEM_CONTROL,
78
+ intent: AACSemanticIntent.PLATFORM_SPECIFIC,
79
+ platformData: {
80
+ gotalkNow: { buttonType },
81
+ },
82
+ fallback: { type: 'ACTION' },
83
+ },
84
+ message: ttsText && ttsText.length > 0 ? ttsText : label,
85
+ };
86
+ }
87
+ case 'TTS':
88
+ default: {
89
+ // When TTSText is empty the visible ButtonText is spoken.
90
+ const spoken = ttsText && ttsText.length > 0 ? ttsText : label;
91
+ return {
92
+ semanticAction: {
93
+ category: AACSemanticCategory.COMMUNICATION,
94
+ intent: AACSemanticIntent.SPEAK_TEXT,
95
+ text: spoken,
96
+ platformData: {
97
+ gotalkNow: { buttonType: 'TTS' },
98
+ },
99
+ fallback: { type: 'SPEAK', message: spoken },
100
+ },
101
+ message: spoken,
102
+ };
103
+ }
104
+ }
105
+ }
106
+ /**
107
+ * Derive a square-ish grid layout from the declared button count.
108
+ * GoTalk NOW uses fixed layouts (1, 4, 9, 16, 25, 36 …).
109
+ */
110
+ function gridDimensionsFromButtonCount(count) {
111
+ const side = Math.max(1, Math.ceil(Math.sqrt(Math.max(count, 1))));
112
+ return { rows: side, cols: side };
113
+ }
114
+ // ---------------------------------------------------------------------------
115
+ // Processor
116
+ // ---------------------------------------------------------------------------
117
+ class GotalkNowProcessor extends BaseProcessor {
118
+ constructor(options) {
119
+ super(options);
120
+ this.capabilities = {
121
+ wordList: 'none',
122
+ preservesAssetsOnSave: true,
123
+ newCellCreation: 'allowed',
124
+ };
125
+ }
126
+ // ---- Zip access ---------------------------------------------------------
127
+ async openZip(filePathOrBuffer) {
128
+ const zip = await this.options.zipAdapter(filePathOrBuffer, this.options.fileAdapter);
129
+ return zip;
130
+ }
131
+ async readPlistFromZip(zip, name, fallback) {
132
+ const files = zip.listFiles();
133
+ if (!files.includes(name)) {
134
+ if (fallback)
135
+ return fallback();
136
+ throw new Error(`GoTalk NOW archive is missing ${name}`);
137
+ }
138
+ const bytes = await zip.readFile(name);
139
+ const text = typeof Buffer !== 'undefined' && Buffer.isBuffer(bytes)
140
+ ? bytes.toString('utf8')
141
+ : new TextDecoder().decode(bytes);
142
+ return plist.parse(text);
143
+ }
144
+ // ---- extractTexts -------------------------------------------------------
145
+ async extractTexts(filePathOrBuffer) {
146
+ const tree = await this.loadIntoTree(filePathOrBuffer);
147
+ const texts = [];
148
+ for (const pageId in tree.pages) {
149
+ const page = tree.pages[pageId];
150
+ if (page.name)
151
+ texts.push(page.name);
152
+ page.buttons.forEach((btn) => {
153
+ if (btn.label)
154
+ texts.push(btn.label);
155
+ if (btn.message && btn.message !== btn.label)
156
+ texts.push(btn.message);
157
+ });
158
+ }
159
+ return texts;
160
+ }
161
+ // ---- loadIntoTree -------------------------------------------------------
162
+ async loadIntoTree(filePathOrBuffer) {
163
+ const { getFileSize, pathExists } = this.options.fileAdapter;
164
+ const filename = typeof filePathOrBuffer === 'string' ? getBasename(filePathOrBuffer) : 'upload.gtbz';
165
+ let zip;
166
+ let pageDataValue;
167
+ let pageOrderValue;
168
+ let bookInfoValue;
169
+ try {
170
+ zip = await this.openZip(filePathOrBuffer);
171
+ pageDataValue = await this.readPlistFromZip(zip, 'PageData.plist');
172
+ pageOrderValue = await this.readPlistFromZip(zip, 'PageOrder.plist', () => []);
173
+ bookInfoValue = await this.readPlistFromZip(zip, 'BookInfo.plist', () => ({}));
174
+ }
175
+ catch (err) {
176
+ if (err instanceof ValidationFailureError)
177
+ throw err;
178
+ const validation = buildValidationResultFromMessage({
179
+ filename,
180
+ filesize: typeof filePathOrBuffer === 'string'
181
+ ? (await pathExists(filePathOrBuffer))
182
+ ? await getFileSize(filePathOrBuffer)
183
+ : 0
184
+ : 0,
185
+ format: 'gotalknow',
186
+ message: err?.message || 'Failed to open GoTalk NOW archive',
187
+ type: 'structure',
188
+ description: 'gtbz archive',
189
+ });
190
+ throw new ValidationFailureError('Failed to load GoTalk NOW file', validation, err);
191
+ }
192
+ const pageData = pageDataValue || {};
193
+ const pageOrderRaw = Array.isArray(pageOrderValue) ? pageOrderValue : [];
194
+ const tree = new AACTree();
195
+ tree.metadata.format = 'gotalknow';
196
+ // Format version
197
+ const bookInfo = bookInfoValue || {};
198
+ if (bookInfo.GoTalkBookFormatVersion !== undefined) {
199
+ const version = bookInfo.GoTalkBookFormatVersion;
200
+ tree.metadata.version =
201
+ typeof version === 'number' || typeof version === 'string' ? String(version) : '1';
202
+ }
203
+ // Preserve the ordered list of page ids for round-trip fidelity.
204
+ const orderedPageIds = pageOrderRaw.map((id) => String(id));
205
+ tree.metadata.pageOrder = orderedPageIds;
206
+ // The first page in PageOrder (if any) is treated as the home page.
207
+ if (orderedPageIds.length > 0) {
208
+ tree.metadata.defaultHomePageId = orderedPageIds[0];
209
+ }
210
+ // Track available image files so we can resolve ButtonImages locations.
211
+ const zipFiles = new Set(zip.listFiles());
212
+ // Build pages. Use the union of PageData keys and PageOrder ids.
213
+ const allPageIds = new Set([...Object.keys(pageData), ...orderedPageIds]);
214
+ // Deterministic ordering: ordered pages first (in order), then any extras sorted numerically.
215
+ const sortedPageIds = [
216
+ ...orderedPageIds.filter((id) => allPageIds.has(id)),
217
+ ...[...allPageIds]
218
+ .filter((id) => !orderedPageIds.includes(id))
219
+ .sort((a, b) => Number(a) - Number(b)),
220
+ ];
221
+ if (sortedPageIds.length === 0) {
222
+ const validation = buildValidationResultFromMessage({
223
+ filename,
224
+ filesize: 0,
225
+ format: 'gotalknow',
226
+ message: 'No pages found in GoTalk NOW PageData.plist',
227
+ type: 'structure',
228
+ description: 'PageData.plist',
229
+ });
230
+ throw new ValidationFailureError('GoTalk NOW file has no pages', validation);
231
+ }
232
+ for (const pageId of sortedPageIds) {
233
+ const rawPage = pageData[pageId] || { Buttons: {} };
234
+ const pageTitle = typeof rawPage.PageTitle === 'string' && rawPage.PageTitle.trim().length > 0
235
+ ? rawPage.PageTitle
236
+ : `Page ${pageId}`;
237
+ const page = new AACPage({
238
+ id: pageId,
239
+ name: pageTitle,
240
+ grid: [],
241
+ buttons: [],
242
+ parentId: null,
243
+ });
244
+ // Page-level style: background colour and title font.
245
+ const pageBg = decodeNsColor(rawPage.BackgroundColor);
246
+ const pageFont = decodeNsFont(rawPage.TitleFont);
247
+ if (pageBg || pageFont.fontFamily) {
248
+ page.style = {
249
+ ...(page.style || {}),
250
+ ...(pageBg ? { backgroundColor: pageBg } : {}),
251
+ ...(pageFont.fontFamily ? { fontFamily: pageFont.fontFamily } : {}),
252
+ };
253
+ }
254
+ if (rawPage.Enabled === false) {
255
+ // Disabled pages are retained but flagged for fidelity.
256
+ page.enabled = false;
257
+ }
258
+ const buttonsById = rawPage.Buttons || {};
259
+ // Numeric sort of button indices ("0","1",…,"10",…)
260
+ const buttonIndices = Object.keys(buttonsById).sort((a, b) => Number(a) - Number(b));
261
+ const buttonCount = rawPage.ButtonCount ?? buttonIndices.length;
262
+ const { rows, cols } = gridDimensionsFromButtonCount(buttonCount);
263
+ const gridLayout = Array.from({ length: rows }, () => Array.from({ length: cols }, () => null));
264
+ buttonIndices.forEach((btnIndex, i) => {
265
+ const rawButton = buttonsById[btnIndex];
266
+ if (!rawButton)
267
+ return;
268
+ const { semanticAction, message, targetPageId } = buildSemanticAction(rawButton, pageId, btnIndex);
269
+ const button = new AACButton({
270
+ id: `${pageId}_btn_${btnIndex}`,
271
+ label: rawButton.ButtonText ?? '',
272
+ message,
273
+ targetPageId,
274
+ semanticAction,
275
+ });
276
+ // Decode style from NSKeyedArchiver blobs.
277
+ const backgroundColor = decodeNsColor(rawButton.BackgroundColor);
278
+ const borderColor = decodeNsColor(rawButton.BorderColor);
279
+ const fontColor = decodeNsColor(rawButton.TextColor);
280
+ const font = decodeNsFont(rawButton.TextFont);
281
+ if (backgroundColor || borderColor || fontColor || font.fontFamily) {
282
+ button.style = {
283
+ ...(button.style || {}),
284
+ ...(backgroundColor ? { backgroundColor } : {}),
285
+ ...(borderColor ? { borderColor } : {}),
286
+ ...(fontColor ? { fontColor } : {}),
287
+ ...(font.fontFamily ? { fontFamily: font.fontFamily } : {}),
288
+ };
289
+ }
290
+ // Resolve image metadata.
291
+ const images = rawButton.ButtonImages;
292
+ if (Array.isArray(images) && images.length > 0) {
293
+ const first = images[0];
294
+ if (first?.Location) {
295
+ button.image = first.Location;
296
+ if (zipFiles.has(first.Location)) {
297
+ button.resolvedImageEntry = first.Location;
298
+ }
299
+ }
300
+ if (first?.SourceLibrary)
301
+ button.symbolLibrary = first.SourceLibrary;
302
+ if (first?.SourceImageName)
303
+ button.symbolPath = first.SourceImageName;
304
+ }
305
+ // Visibility: GoTalk buttons can be disabled.
306
+ if (rawButton.Enabled === false) {
307
+ button.visibility = 'Disabled';
308
+ }
309
+ // Preserve raw style data opaquely for round-trip styling.
310
+ button.parameters = {
311
+ ...(button.parameters || {}),
312
+ __gotalkNow: {
313
+ buttonType: rawButton.ButtonType,
314
+ textShadow: rawButton.TextShadow,
315
+ textCenter: rawButton.TextCenter,
316
+ },
317
+ };
318
+ page._loadButton(button);
319
+ // Place into grid by index (row-major).
320
+ const row = Math.floor(i / cols);
321
+ const col = i % cols;
322
+ if (row < rows && col < cols) {
323
+ gridLayout[row][col] = button;
324
+ }
325
+ });
326
+ page.grid = gridLayout;
327
+ tree.addPage(page);
328
+ }
329
+ return tree;
330
+ }
331
+ // ---- processTexts (asset-preserving round-trip) -------------------------
332
+ async processTexts(filePathOrBuffer, translations, outputPath) {
333
+ const { writeBinaryToPath } = this.options.fileAdapter;
334
+ const originalZip = await this.openZip(filePathOrBuffer);
335
+ // Parse PageData and apply translations in place.
336
+ const pageDataBytes = await originalZip.readFile('PageData.plist');
337
+ const pageDataText = typeof Buffer !== 'undefined' && Buffer.isBuffer(pageDataBytes)
338
+ ? pageDataBytes.toString('utf8')
339
+ : new TextDecoder().decode(pageDataBytes);
340
+ const pageData = plist.parse(pageDataText);
341
+ for (const pageId of Object.keys(pageData)) {
342
+ const rawPage = pageData[pageId];
343
+ if (!rawPage || !rawPage.Buttons)
344
+ continue;
345
+ for (const btnIndex of Object.keys(rawPage.Buttons)) {
346
+ const rawButton = rawPage.Buttons[btnIndex];
347
+ if (!rawButton)
348
+ continue;
349
+ // Translate the visible label.
350
+ if (rawButton.ButtonText && translations.has(rawButton.ButtonText)) {
351
+ rawButton.ButtonText = translations.get(rawButton.ButtonText);
352
+ }
353
+ // Translate the spoken text (only when explicitly set).
354
+ const actionData = rawButton.ActionData;
355
+ if (actionData && actionData.TTSText && translations.has(actionData.TTSText)) {
356
+ actionData.TTSText = translations.get(actionData.TTSText);
357
+ }
358
+ }
359
+ }
360
+ const rebuiltPageData = plist.build(pageData);
361
+ // Repack: copy every original entry verbatim, replacing only PageData.plist.
362
+ const outputZip = await this.options.zipAdapter(undefined, this.options.fileAdapter);
363
+ const files = [];
364
+ for (const name of originalZip.listFiles()) {
365
+ if (name === 'PageData.plist')
366
+ continue;
367
+ files.push({ name, data: await originalZip.readFile(name) });
368
+ }
369
+ files.push({ name: 'PageData.plist', data: rebuiltPageData });
370
+ const outputBuffer = await outputZip.writeFiles(files);
371
+ await writeBinaryToPath(outputPath, outputBuffer);
372
+ return outputBuffer;
373
+ }
374
+ // ---- saveFromTree (build from tree) -------------------------------------
375
+ async saveFromTree(tree, outputPath) {
376
+ const { writeBinaryToPath } = this.options.fileAdapter;
377
+ const pageData = {};
378
+ const pageOrder = [];
379
+ const imageFiles = [];
380
+ const pageIds = Object.keys(tree.pages);
381
+ pageIds.forEach((pageId, pageIdx) => {
382
+ const page = tree.pages[pageId];
383
+ const numericId = Number(pageId);
384
+ if (!Number.isNaN(numericId))
385
+ pageOrder.push(numericId);
386
+ const buttons = {};
387
+ const buttonList = page.buttons;
388
+ buttonList.forEach((btn, i) => {
389
+ const btnIndex = String(i);
390
+ const buttonType = btn.parameters?.__gotalkNow?.buttonType ||
391
+ (btn.targetPageId ? 'Jump' : 'TTS');
392
+ const actionData = {};
393
+ if (buttonType === 'Jump' && btn.targetPageId) {
394
+ actionData.JumpTo = Number(btn.targetPageId);
395
+ actionData.TTSText = '';
396
+ }
397
+ else if (buttonType === 'TTS') {
398
+ // Only store TTSText when it differs from the label (matches GoTalk's convention).
399
+ actionData.TTSText = btn.message && btn.message !== btn.label ? btn.message : '';
400
+ }
401
+ else {
402
+ actionData.TTSText = btn.message || '';
403
+ }
404
+ const rawButton = {
405
+ ActionData: actionData,
406
+ ButtonType: buttonType,
407
+ ButtonText: btn.label || '',
408
+ };
409
+ if (btn.resolvedImageEntry || btn.image) {
410
+ rawButton.ButtonImages = [
411
+ {
412
+ Location: btn.resolvedImageEntry || btn.image,
413
+ Type: 'Saved',
414
+ },
415
+ ];
416
+ }
417
+ buttons[btnIndex] = rawButton;
418
+ });
419
+ pageData[pageId] = {
420
+ ButtonCount: buttonList.length,
421
+ Buttons: buttons,
422
+ };
423
+ // Collect referenced images so we can include them in the archive.
424
+ buttonList.forEach((btn) => {
425
+ if (btn.resolvedImageEntry) {
426
+ // Image bytes are not available from the tree alone; skip if absent.
427
+ }
428
+ });
429
+ void pageIdx;
430
+ });
431
+ const bookInfo = {
432
+ GoTalkBookFormatVersion: Number(tree.metadata.version) || 1,
433
+ };
434
+ const zip = await this.options.zipAdapter(undefined, this.options.fileAdapter);
435
+ const files = [
436
+ { name: 'BookInfo.plist', data: plist.build(bookInfo) },
437
+ { name: 'PageData.plist', data: plist.build(pageData) },
438
+ {
439
+ name: 'PageOrder.plist',
440
+ data: plist.build(pageOrder),
441
+ },
442
+ { name: 'PageHistory.plist', data: plist.build([]) },
443
+ ...imageFiles,
444
+ ];
445
+ const outputBuffer = await zip.writeFiles(files);
446
+ await writeBinaryToPath(outputPath, outputBuffer);
447
+ }
448
+ /**
449
+ * Extract strings with metadata for aac-tools-platform compatibility.
450
+ * Uses the generic implementation from BaseProcessor.
451
+ */
452
+ extractStringsWithMetadata(filePath) {
453
+ return this.extractStringsWithMetadataGeneric(filePath);
454
+ }
455
+ /**
456
+ * Generate translated download for aac-tools-platform compatibility.
457
+ * Uses the generic implementation from BaseProcessor.
458
+ */
459
+ generateTranslatedDownload(filePath, translatedStrings, sourceStrings) {
460
+ return this.generateTranslatedDownloadGeneric(filePath, translatedStrings, sourceStrings);
461
+ }
462
+ }
463
+ export { GotalkNowProcessor };
@@ -688,7 +688,9 @@ class ObfProcessor extends BaseProcessor {
688
688
  });
689
689
  const manifest = {
690
690
  format: OBF_FORMAT_VERSION,
691
- root: tree.metadata.defaultHomePageId,
691
+ root: tree.metadata.defaultHomePageId
692
+ ? this.getPageFilename(tree.metadata.defaultHomePageId, tree.metadata)
693
+ : undefined,
692
694
  paths: {
693
695
  boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [
694
696
  id,
@@ -755,7 +757,9 @@ class ObfProcessor extends BaseProcessor {
755
757
  if (Object.keys(tree.pages).length > 0) {
756
758
  const manifest = {
757
759
  format: OBF_FORMAT_VERSION,
758
- root: tree.metadata.defaultHomePageId,
760
+ root: tree.metadata.defaultHomePageId
761
+ ? this.getPageFilename(tree.metadata.defaultHomePageId, tree.metadata)
762
+ : undefined,
759
763
  paths: {
760
764
  boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [
761
765
  id,