@willwade/aac-processors 0.3.1 → 0.3.3

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.
@@ -5,3 +5,4 @@
5
5
  * This is separate from pageset metrics.
6
6
  */
7
7
  export * from './utilities/analytics/history';
8
+ export * from './utilities/analytics/competence';
package/dist/analytics.js CHANGED
@@ -21,3 +21,4 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
21
21
  };
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
23
  __exportStar(require("./utilities/analytics/history"), exports);
24
+ __exportStar(require("./utilities/analytics/competence"), exports);
@@ -54,6 +54,12 @@ export function getProcessor(filePathOrExtension, options) {
54
54
  const extension = filePathOrExtension.includes('.')
55
55
  ? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.'))
56
56
  : filePathOrExtension;
57
+ // GoTalk NOW share exports keep their book suffix before the archive
58
+ // extension (e.g. "MyBook.gotalk-book.zip"), so match on the name, not the
59
+ // final extension.
60
+ if (/\.gotalk-book(\.|$)/i.test(filePathOrExtension)) {
61
+ return new GotalkNowProcessor(options);
62
+ }
57
63
  switch (extension.toLowerCase()) {
58
64
  case '.dot':
59
65
  return new DotProcessor(options);
@@ -96,6 +102,7 @@ export function getSupportedExtensions() {
96
102
  '.plist',
97
103
  '.grd',
98
104
  '.gtbz',
105
+ '.gotalk-book',
99
106
  ];
100
107
  }
101
108
  /**
@@ -10,6 +10,7 @@ export * from './utilities/analytics/metrics/obl-types';
10
10
  export { OblUtil, OblAnonymizer } from './utilities/analytics/metrics/obl';
11
11
  export { MetricsCalculator } from './utilities/analytics/metrics/core';
12
12
  export { VocabularyAnalyzer } from './utilities/analytics/metrics/vocabulary';
13
+ export { dumpVocabulary } from './utilities/analytics/metrics/vocabularyDump';
13
14
  export { SentenceAnalyzer } from './utilities/analytics/metrics/sentence';
14
15
  export { ComparisonAnalyzer } from './utilities/analytics/metrics/comparison';
15
16
  export { MorphologyEngine } from './utilities/analytics/morphology';
@@ -17,6 +17,13 @@
17
17
  * - Backup-<n>.zip – rolling backups
18
18
  * - REQUIRESREGEN – marker file
19
19
  *
20
+ * Two on-disk variants exist and both are accepted:
21
+ * - `.gtbz` app backups: plists at the ZIP root.
22
+ * - `.gotalk-book` share exports (often `.zip`-suffixed): everything nested
23
+ * under a `<BookName>.gotalk-book/` folder, possibly alongside `__MACOSX/`
24
+ * resource-fork entries (ignored). Button images are pre-rendered
25
+ * snapshots named `<page>-<button>-<buttonCount>.png`.
26
+ *
20
27
  * Colours and fonts in PageData are NSKeyedArchiver-encoded binary plists
21
28
  * carried inside `<data>` tags. They are treated as opaque blobs and preserved
22
29
  * verbatim so that only text fields are touched during translation.
@@ -39,7 +46,8 @@ function buildSemanticAction(rawButton, pageId, buttonIndex) {
39
46
  switch (buttonType) {
40
47
  case 'Jump': {
41
48
  const jumpTo = actionData.JumpTo;
42
- const target = jumpTo !== undefined ? String(jumpTo) : undefined;
49
+ // GoTalk treats 0 / -1 / "" as "no jump configured".
50
+ const target = !isNoJumpTarget(jumpTo) ? String(jumpTo) : undefined;
43
51
  return {
44
52
  semanticAction: {
45
53
  category: AACSemanticCategory.NAVIGATION,
@@ -48,7 +56,7 @@ function buildSemanticAction(rawButton, pageId, buttonIndex) {
48
56
  platformData: {
49
57
  gotalkNow: { buttonType: 'Jump', jumpTo, jumpBook: actionData.JumpBook },
50
58
  },
51
- fallback: { type: 'NAVIGATE', targetPageId: target },
59
+ fallback: target ? { type: 'NAVIGATE', targetPageId: target } : { type: 'ACTION' },
52
60
  },
53
61
  message: '',
54
62
  targetPageId: target,
@@ -157,12 +165,62 @@ function buildSemanticAction(rawButton, pageId, buttonIndex) {
157
165
  }
158
166
  }
159
167
  /**
160
- * Derive a square-ish grid layout from the declared button count.
161
- * GoTalk NOW uses fixed layouts (1, 4, 9, 16, 25, 36 …).
168
+ * Derive a GoTalk NOW layout from the declared button count.
169
+ * GoTalk NOW uses fixed square layouts (1, 4, 9, 16, 25, 36 …); when the
170
+ * count is not a perfect square (alternate/custom layouts), fall back to a
171
+ * near-square rectangle: cols = ceil(sqrt(n)), rows = ceil(n / cols).
162
172
  */
163
173
  function gridDimensionsFromButtonCount(count) {
164
- const side = Math.max(1, Math.ceil(Math.sqrt(Math.max(count, 1))));
165
- return { rows: side, cols: side };
174
+ const n = Math.max(count, 1);
175
+ const side = Math.ceil(Math.sqrt(n));
176
+ return { rows: Math.ceil(n / side), cols: side };
177
+ }
178
+ /** GoTalk treats JumpTo 0 / -1 / "" as "no jump". */
179
+ function isNoJumpTarget(value) {
180
+ return (value === undefined ||
181
+ value === null ||
182
+ value === 0 ||
183
+ value === -1 ||
184
+ value === '' ||
185
+ value === '0' ||
186
+ value === '-1');
187
+ }
188
+ /** Normalise a zip or iOS-container path to a lowercase basename. */
189
+ function basenameLower(p) {
190
+ if (!p)
191
+ return null;
192
+ const base = p.replace(/\\/g, '/').split('/').pop() || '';
193
+ const trimmed = base.trim().toLowerCase();
194
+ return trimmed.length > 0 ? trimmed : null;
195
+ }
196
+ /**
197
+ * Resolve a bundled image for a button against the archive's file list.
198
+ *
199
+ * `Location`/`QuickRecoveryPath` values are iOS app-container paths that never
200
+ * match archive entry names verbatim, so candidates are matched by basename
201
+ * (case-insensitive), with extension guessing when the name has none.
202
+ */
203
+ function findZipImageEntry(zipBasenames, candidates) {
204
+ const tried = new Set();
205
+ for (const candidate of candidates) {
206
+ const base = basenameLower(candidate);
207
+ if (!base || tried.has(base))
208
+ continue;
209
+ tried.add(base);
210
+ // 1. Exact basename match.
211
+ const direct = zipBasenames.get(base);
212
+ if (direct) {
213
+ const ext = base.includes('.') ? base.slice(base.lastIndexOf('.')) : '.png';
214
+ return { entry: direct, ext };
215
+ }
216
+ // 2. Try common image extensions.
217
+ for (const ext of ['.png', '.jpg', '.jpeg', '.bmp']) {
218
+ const hit = zipBasenames.get(base + ext);
219
+ if (hit)
220
+ return { entry: hit, ext };
221
+ }
222
+ }
223
+ return null;
166
224
  }
167
225
  // ---------------------------------------------------------------------------
168
226
  // Processor
@@ -181,14 +239,27 @@ class GotalkNowProcessor extends BaseProcessor {
181
239
  const zip = await this.options.zipAdapter(filePathOrBuffer, this.options.fileAdapter);
182
240
  return zip;
183
241
  }
184
- async readPlistFromZip(zip, name, fallback) {
185
- const files = zip.listFiles();
186
- if (!files.includes(name)) {
242
+ /**
243
+ * Resolve a plist entry name inside the archive, tolerating both layout
244
+ * variants: `.gtbz` (plists at ZIP root) and `.gotalk-book` share exports
245
+ * (plists nested under a `<BookName>.gotalk-book/` folder). `__MACOSX`
246
+ * resource-fork entries are ignored. Returns the full entry name, or null.
247
+ */
248
+ resolveEntryName(files, basename, prefix) {
249
+ const rooted = `${prefix}${basename}`;
250
+ if (files.includes(rooted))
251
+ return rooted;
252
+ // Nested variant: find the (non-__MACOSX) entry ending in /<basename>.
253
+ const matches = files.filter((f) => f !== `__MACOSX` && !f.startsWith('__MACOSX/') && f.endsWith(`/${basename}`));
254
+ return matches.length > 0 ? matches[0] : null;
255
+ }
256
+ async readPlistFromZip(zip, name, resolvedName, fallback) {
257
+ if (!resolvedName) {
187
258
  if (fallback)
188
259
  return fallback();
189
260
  throw new Error(`GoTalk NOW archive is missing ${name}`);
190
261
  }
191
- const bytes = await zip.readFile(name);
262
+ const bytes = await zip.readFile(resolvedName);
192
263
  const text = typeof Buffer !== 'undefined' && Buffer.isBuffer(bytes)
193
264
  ? bytes.toString('utf8')
194
265
  : new TextDecoder().decode(bytes);
@@ -221,9 +292,13 @@ class GotalkNowProcessor extends BaseProcessor {
221
292
  let bookInfoValue;
222
293
  try {
223
294
  zip = await this.openZip(filePathOrBuffer);
224
- pageDataValue = await this.readPlistFromZip(zip, 'PageData.plist');
225
- pageOrderValue = await this.readPlistFromZip(zip, 'PageOrder.plist', () => []);
226
- bookInfoValue = await this.readPlistFromZip(zip, 'BookInfo.plist', () => ({}));
295
+ // Resolve plist locations once; shared by load/processTexts round-trip.
296
+ const files = zip.listFiles();
297
+ const pdEntry = this.resolveEntryName(files, 'PageData.plist', '');
298
+ const prefix = pdEntry ? pdEntry.slice(0, -'PageData.plist'.length) : '';
299
+ pageDataValue = await this.readPlistFromZip(zip, 'PageData.plist', pdEntry);
300
+ pageOrderValue = await this.readPlistFromZip(zip, 'PageOrder.plist', this.resolveEntryName(files, 'PageOrder.plist', prefix), () => []);
301
+ bookInfoValue = await this.readPlistFromZip(zip, 'BookInfo.plist', this.resolveEntryName(files, 'BookInfo.plist', prefix), () => ({}));
227
302
  }
228
303
  catch (err) {
229
304
  if (err instanceof ValidationFailureError)
@@ -262,6 +337,16 @@ class GotalkNowProcessor extends BaseProcessor {
262
337
  }
263
338
  // Track available image files so we can resolve ButtonImages locations.
264
339
  const zipFiles = new Set(zip.listFiles());
340
+ // Basename (lowercase, non-__MACOSX) -> full entry name, for tolerant
341
+ // matching of bundled images in .gotalk-book share exports.
342
+ const zipBasenames = new Map();
343
+ for (const f of zipFiles) {
344
+ if (f.startsWith('__MACOSX/') || f.endsWith('/'))
345
+ continue;
346
+ const base = basenameLower(f);
347
+ if (base && !zipBasenames.has(base))
348
+ zipBasenames.set(base, f);
349
+ }
265
350
  // Build pages. Use the union of PageData keys and PageOrder ids.
266
351
  const allPageIds = new Set([...Object.keys(pageData), ...orderedPageIds]);
267
352
  // Deterministic ordering: ordered pages first (in order), then any extras sorted numerically.
@@ -311,7 +396,10 @@ class GotalkNowProcessor extends BaseProcessor {
311
396
  const buttonsById = rawPage.Buttons || {};
312
397
  // Numeric sort of button indices ("0","1",…,"10",…)
313
398
  const buttonIndices = Object.keys(buttonsById).sort((a, b) => Number(a) - Number(b));
314
- const buttonCount = rawPage.ButtonCount ?? buttonIndices.length;
399
+ // Sparse button maps: the grid size is max(index)+1, not the number of
400
+ // keys (GoTalk snapshots and layouts are sized by the highest slot).
401
+ const buttonCount = rawPage.ButtonCount ??
402
+ (buttonIndices.length > 0 ? Math.max(...buttonIndices.map(Number)) + 1 : 0);
315
403
  const { rows, cols } = gridDimensionsFromButtonCount(buttonCount);
316
404
  const gridLayout = Array.from({ length: rows }, () => Array.from({ length: cols }, () => null));
317
405
  buttonIndices.forEach((btnIndex, i) => {
@@ -341,19 +429,47 @@ class GotalkNowProcessor extends BaseProcessor {
341
429
  };
342
430
  }
343
431
  // Resolve image metadata.
344
- const images = rawButton.ButtonImages;
345
- if (Array.isArray(images) && images.length > 0) {
346
- const first = images[0];
347
- if (first?.Location) {
348
- button.image = first.Location;
349
- if (zipFiles.has(first.Location)) {
350
- button.resolvedImageEntry = first.Location;
432
+ // Heuristics (from real .gotalk-book share exports):
433
+ // - entries sourced from the app's bundled "GoTalk Image Library"
434
+ // are clip-art, not user content — skip them;
435
+ // - prefer the entry carrying QuickRecoveryPath; with multiple
436
+ // entries the second one is the user's image;
437
+ // - bundled files are matched by basename (Location/QRP are iOS
438
+ // container paths, never archive entry names);
439
+ // - last resort: the pre-rendered snapshot <page>-<btn>-<count>.png.
440
+ const imagesAll = rawButton.ButtonImages;
441
+ const images = Array.isArray(imagesAll)
442
+ ? imagesAll.filter((im) => (im?.SourceLibrary || '') !== 'GoTalk Image Library')
443
+ : [];
444
+ if (images.length > 0) {
445
+ const selected = images.find((im) => im.QuickRecoveryPath !== undefined) ??
446
+ (images.length >= 2 ? images[1] : images[0]);
447
+ if (selected) {
448
+ const sourceLibrary = (selected.SourceLibrary || '').toLowerCase();
449
+ const sourceImageName = selected.SourceImageName || '';
450
+ const isLibrarySymbol = /metacom|pcs|symbolstix|widgit/.test(sourceLibrary) && sourceImageName.length > 0;
451
+ if (selected.Location)
452
+ button.image = selected.Location;
453
+ if (selected.SourceLibrary)
454
+ button.symbolLibrary = selected.SourceLibrary;
455
+ if (sourceImageName)
456
+ button.symbolPath = sourceImageName;
457
+ if (!isLibrarySymbol) {
458
+ const bundled = findZipImageEntry(zipBasenames, [
459
+ selected.QuickRecoveryPath,
460
+ selected.Location,
461
+ selected.SourceImageName,
462
+ `${pageId}-${btnIndex}-${buttonCount}.png`,
463
+ ]);
464
+ if (bundled) {
465
+ button.image = bundled.entry;
466
+ button.resolvedImageEntry = bundled.entry;
467
+ }
468
+ }
469
+ else if (zipFiles.has(selected.Location || '')) {
470
+ button.resolvedImageEntry = selected.Location;
351
471
  }
352
472
  }
353
- if (first?.SourceLibrary)
354
- button.symbolLibrary = first.SourceLibrary;
355
- if (first?.SourceImageName)
356
- button.symbolPath = first.SourceImageName;
357
473
  }
358
474
  // Visibility: GoTalk buttons can be disabled via either field.
359
475
  if (rawButton.Enabled === false || rawButton.Disabled === true) {
@@ -407,8 +523,12 @@ class GotalkNowProcessor extends BaseProcessor {
407
523
  async processTexts(filePathOrBuffer, translations, outputPath) {
408
524
  const { writeBinaryToPath } = this.options.fileAdapter;
409
525
  const originalZip = await this.openZip(filePathOrBuffer);
410
- // Parse PageData and apply translations in place.
411
- const pageDataBytes = await originalZip.readFile('PageData.plist');
526
+ // Parse PageData and apply translations in place. Locate the plist the
527
+ // same tolerant way loadIntoTree does (root or nested .gotalk-book folder)
528
+ // and write it back under its original entry name so the layout survives.
529
+ const files = originalZip.listFiles();
530
+ const pdEntry = this.resolveEntryName(files, 'PageData.plist', '') ?? 'PageData.plist';
531
+ const pageDataBytes = await originalZip.readFile(pdEntry);
412
532
  const pageDataText = typeof Buffer !== 'undefined' && Buffer.isBuffer(pageDataBytes)
413
533
  ? pageDataBytes.toString('utf8')
414
534
  : new TextDecoder().decode(pageDataBytes);
@@ -435,14 +555,14 @@ class GotalkNowProcessor extends BaseProcessor {
435
555
  const rebuiltPageData = plist.build(pageData);
436
556
  // Repack: copy every original entry verbatim, replacing only PageData.plist.
437
557
  const outputZip = await this.options.zipAdapter(undefined, this.options.fileAdapter);
438
- const files = [];
558
+ const filesOut = [];
439
559
  for (const name of originalZip.listFiles()) {
440
- if (name === 'PageData.plist')
560
+ if (name === pdEntry)
441
561
  continue;
442
- files.push({ name, data: await originalZip.readFile(name) });
562
+ filesOut.push({ name, data: await originalZip.readFile(name) });
443
563
  }
444
- files.push({ name: 'PageData.plist', data: rebuiltPageData });
445
- const outputBuffer = await outputZip.writeFiles(files);
564
+ filesOut.push({ name: pdEntry, data: rebuiltPageData });
565
+ const outputBuffer = await outputZip.writeFiles(filesOut);
446
566
  await writeBinaryToPath(outputPath, outputBuffer);
447
567
  return outputBuffer;
448
568
  }