@willwade/aac-processors 0.3.2 → 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.
- package/dist/browser/index.browser.js +7 -0
- package/dist/browser/metrics.js +1 -0
- package/dist/browser/processors/gotalkNowProcessor.js +152 -32
- package/dist/browser/utilities/analytics/metrics/vocabularyDump.js +188 -0
- package/dist/cli/index.js +79 -0
- package/dist/core/analyze.js +1 -0
- package/dist/index.browser.js +7 -0
- package/dist/index.node.js +7 -0
- package/dist/metrics.d.ts +2 -0
- package/dist/metrics.js +3 -1
- package/dist/processors/gotalkNowProcessor.d.ts +14 -0
- package/dist/processors/gotalkNowProcessor.js +152 -32
- package/dist/types/aac.d.ts +1 -0
- package/dist/utilities/analytics/competence.d.ts +7 -0
- package/dist/utilities/analytics/index.d.ts +2 -0
- package/dist/utilities/analytics/index.js +3 -1
- package/dist/utilities/analytics/metrics/vocabularyDump.d.ts +88 -0
- package/dist/utilities/analytics/metrics/vocabularyDump.js +191 -0
- package/package.json +1 -1
|
@@ -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
|
/**
|
package/dist/browser/metrics.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
165
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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(
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
|
558
|
+
const filesOut = [];
|
|
439
559
|
for (const name of originalZip.listFiles()) {
|
|
440
|
-
if (name ===
|
|
560
|
+
if (name === pdEntry)
|
|
441
561
|
continue;
|
|
442
|
-
|
|
562
|
+
filesOut.push({ name, data: await originalZip.readFile(name) });
|
|
443
563
|
}
|
|
444
|
-
|
|
445
|
-
const outputBuffer = await outputZip.writeFiles(
|
|
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
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vocabulary Dump (counts-only)
|
|
3
|
+
*
|
|
4
|
+
* Inventory of the vocabulary available in an AAC pageset, aggregated by
|
|
5
|
+
* source and part of speech. Only counts are emitted — never word lists —
|
|
6
|
+
* so the output is safe to embed in privacy-preserving reports.
|
|
7
|
+
*
|
|
8
|
+
* Sources counted:
|
|
9
|
+
* buttons — labelled buttons (the static on-board vocabulary)
|
|
10
|
+
* wordLists — page WordLists feeding dynamic AutoContent cells
|
|
11
|
+
* (Grid 3 `<WordList>`; absent in other formats)
|
|
12
|
+
* predictionDictionaries — prediction wordlists attached to prediction cells
|
|
13
|
+
* (Grid 3 `Prediction.PredictThis` dictionaries)
|
|
14
|
+
* wordForms — smart-grammar inflections generated by
|
|
15
|
+
* MetricsCalculator (only when a MetricsResult is
|
|
16
|
+
* supplied)
|
|
17
|
+
*
|
|
18
|
+
* Non-Grid 3 formats simply report zeros for the Grid 3-specific sources.
|
|
19
|
+
*/
|
|
20
|
+
/** Normalise an entry: trim, lowercase, collapse internal whitespace. */
|
|
21
|
+
function normalize(text) {
|
|
22
|
+
return text.trim().toLowerCase().replace(/\s+/g, ' ');
|
|
23
|
+
}
|
|
24
|
+
const UNTAGGED = 'Unknown';
|
|
25
|
+
/** Per-source tally accumulated while walking the tree. */
|
|
26
|
+
class SourceTally {
|
|
27
|
+
constructor() {
|
|
28
|
+
this.entries = 0;
|
|
29
|
+
this.words = new Set();
|
|
30
|
+
this.phrases = new Set();
|
|
31
|
+
this.byPos = new Map();
|
|
32
|
+
}
|
|
33
|
+
add(text, pos) {
|
|
34
|
+
const norm = normalize(text);
|
|
35
|
+
if (!norm)
|
|
36
|
+
return;
|
|
37
|
+
this.entries++;
|
|
38
|
+
if (/\s/.test(norm))
|
|
39
|
+
this.phrases.add(norm);
|
|
40
|
+
else
|
|
41
|
+
this.words.add(norm);
|
|
42
|
+
const tag = pos && pos.trim() ? pos.trim() : UNTAGGED;
|
|
43
|
+
let set = this.byPos.get(tag);
|
|
44
|
+
if (!set) {
|
|
45
|
+
set = new Set();
|
|
46
|
+
this.byPos.set(tag, set);
|
|
47
|
+
}
|
|
48
|
+
set.add(norm);
|
|
49
|
+
}
|
|
50
|
+
counts() {
|
|
51
|
+
const byPartOfSpeech = {};
|
|
52
|
+
for (const tag of Array.from(this.byPos.keys()).sort((a, b) => a.localeCompare(b))) {
|
|
53
|
+
byPartOfSpeech[tag] = this.byPos.get(tag).size;
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
entries: this.entries,
|
|
57
|
+
uniqueWords: this.words.size,
|
|
58
|
+
uniquePhrases: this.phrases.size,
|
|
59
|
+
byPartOfSpeech,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
get wordSet() {
|
|
63
|
+
return this.words;
|
|
64
|
+
}
|
|
65
|
+
get phraseSet() {
|
|
66
|
+
return this.phrases;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Count the vocabulary available in a pageset, by source and part of speech.
|
|
71
|
+
* Emits counts only — no word lists.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* const processor = new GridsetProcessor();
|
|
75
|
+
* const tree = await processor.loadIntoTree('my.gridset');
|
|
76
|
+
* const metrics = new MetricsCalculator().analyze(tree);
|
|
77
|
+
* const dump = dumpVocabulary(tree, { metrics });
|
|
78
|
+
* console.log(dump.summary.wordLists.lists, 'wordlists');
|
|
79
|
+
*/
|
|
80
|
+
export function dumpVocabulary(tree, options) {
|
|
81
|
+
const pages = Object.values(tree.pages);
|
|
82
|
+
const buttonTally = new SourceTally();
|
|
83
|
+
const wordListTally = new SourceTally();
|
|
84
|
+
const predictionTally = new SourceTally();
|
|
85
|
+
let wordListCount = 0;
|
|
86
|
+
let buttonsWithDictionaries = 0;
|
|
87
|
+
let totalButtons = 0;
|
|
88
|
+
for (const page of pages) {
|
|
89
|
+
totalButtons += page.buttons.length;
|
|
90
|
+
if (page.wordListItems && page.wordListItems.length > 0) {
|
|
91
|
+
wordListCount++;
|
|
92
|
+
for (const item of page.wordListItems) {
|
|
93
|
+
wordListTally.add(item.text, item.partOfSpeech);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
for (const btn of page.buttons) {
|
|
97
|
+
const label = btn.label?.trim();
|
|
98
|
+
if (label) {
|
|
99
|
+
buttonTally.add(label, btn.pos);
|
|
100
|
+
}
|
|
101
|
+
// Prediction dictionaries: parameters.predictions keeps the original
|
|
102
|
+
// Prediction.PredictThis words even after analyze() expands
|
|
103
|
+
// btn.predictions with morphological forms.
|
|
104
|
+
const dict = btn.parameters?.predictions;
|
|
105
|
+
if (Array.isArray(dict) && dict.length > 0) {
|
|
106
|
+
buttonsWithDictionaries++;
|
|
107
|
+
for (const w of dict) {
|
|
108
|
+
if (typeof w === 'string') {
|
|
109
|
+
predictionTally.add(w, btn.pos);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Smart-grammar word forms: inflected forms generated by MetricsCalculator
|
|
116
|
+
// (is_word_form buttons), excluding the original dictionary words they
|
|
117
|
+
// were generated from.
|
|
118
|
+
let wordForms = null;
|
|
119
|
+
let formTally = null;
|
|
120
|
+
if (options?.metrics) {
|
|
121
|
+
const originals = new Set();
|
|
122
|
+
for (const page of pages) {
|
|
123
|
+
for (const btn of page.buttons) {
|
|
124
|
+
const dict = btn.parameters?.predictions;
|
|
125
|
+
if (Array.isArray(dict)) {
|
|
126
|
+
for (const w of dict) {
|
|
127
|
+
if (typeof w === 'string') {
|
|
128
|
+
const norm = normalize(w);
|
|
129
|
+
if (norm)
|
|
130
|
+
originals.add(norm);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
formTally = new SourceTally();
|
|
137
|
+
const parents = new Set();
|
|
138
|
+
const seen = new Set();
|
|
139
|
+
for (const b of options.metrics.buttons) {
|
|
140
|
+
if (!b.is_word_form)
|
|
141
|
+
continue;
|
|
142
|
+
const norm = normalize(b.label);
|
|
143
|
+
if (!norm || seen.has(norm) || originals.has(norm))
|
|
144
|
+
continue;
|
|
145
|
+
seen.add(norm);
|
|
146
|
+
formTally.add(b.label, b.pos);
|
|
147
|
+
if (b.parent_button_id)
|
|
148
|
+
parents.add(b.parent_button_id);
|
|
149
|
+
}
|
|
150
|
+
wordForms = { ...formTally.counts(), parentButtons: parents.size };
|
|
151
|
+
}
|
|
152
|
+
const combinedWords = new Set([
|
|
153
|
+
...buttonTally.wordSet,
|
|
154
|
+
...wordListTally.wordSet,
|
|
155
|
+
...predictionTally.wordSet,
|
|
156
|
+
...(formTally ? formTally.wordSet : []),
|
|
157
|
+
]);
|
|
158
|
+
const combinedPhrases = new Set([
|
|
159
|
+
...buttonTally.phraseSet,
|
|
160
|
+
...wordListTally.phraseSet,
|
|
161
|
+
...predictionTally.phraseSet,
|
|
162
|
+
]);
|
|
163
|
+
return {
|
|
164
|
+
schema: 'aac-vocabulary-dump/v1',
|
|
165
|
+
generatedAt: new Date().toISOString(),
|
|
166
|
+
source: {
|
|
167
|
+
format: tree.metadata?.format,
|
|
168
|
+
name: tree.metadata?.name,
|
|
169
|
+
locale: tree.metadata?.locale,
|
|
170
|
+
},
|
|
171
|
+
summary: {
|
|
172
|
+
totalBoards: pages.length,
|
|
173
|
+
totalButtons,
|
|
174
|
+
buttons: buttonTally.counts(),
|
|
175
|
+
wordLists: { ...wordListTally.counts(), lists: wordListCount },
|
|
176
|
+
predictionDictionaries: {
|
|
177
|
+
...predictionTally.counts(),
|
|
178
|
+
buttonsWithDictionaries,
|
|
179
|
+
},
|
|
180
|
+
wordForms,
|
|
181
|
+
combined: {
|
|
182
|
+
uniqueEntries: combinedWords.size + combinedPhrases.size,
|
|
183
|
+
uniqueWords: combinedWords.size,
|
|
184
|
+
uniquePhrases: combinedPhrases.size,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -6,6 +6,7 @@ const prettyPrint_1 = require("./prettyPrint");
|
|
|
6
6
|
const analyze_1 = require("../core/analyze");
|
|
7
7
|
const history_1 = require("../utilities/analytics/history");
|
|
8
8
|
const analytics_1 = require("../utilities/analytics");
|
|
9
|
+
const zip_1 = require("../utils/zip");
|
|
9
10
|
const aac_1 = require("../types/aac");
|
|
10
11
|
const io_1 = require("../utils/io");
|
|
11
12
|
const node_fs_1 = require("node:fs");
|
|
@@ -28,6 +29,26 @@ async function detectFormat(filePath) {
|
|
|
28
29
|
if (filePath.endsWith('.gtbz')) {
|
|
29
30
|
return 'gotalknow';
|
|
30
31
|
}
|
|
32
|
+
// GoTalk NOW share exports keep their book suffix before the archive
|
|
33
|
+
// extension ("MyBook.gotalk-book.zip") — route on the book suffix.
|
|
34
|
+
if (/\.gotalk-book(\.|$)/i.test(filePath)) {
|
|
35
|
+
return 'gotalknow';
|
|
36
|
+
}
|
|
37
|
+
// Bare .zip: sniff the archive for GoTalk NOW plists (root or nested
|
|
38
|
+
// <BookName>.gotalk-book/ folder). Other zip-based formats (.obz, .spb,
|
|
39
|
+
// .gridset) always arrive with their own extension.
|
|
40
|
+
if (filePath.toLowerCase().endsWith('.zip')) {
|
|
41
|
+
try {
|
|
42
|
+
const zip = await (0, zip_1.getZipAdapter)(filePath, io_1.defaultFileAdapter);
|
|
43
|
+
const files = zip.listFiles();
|
|
44
|
+
const hasGoTalkPlists = files.some((f) => !f.startsWith('__MACOSX/') && /(^|\/)PageData\.plist$/.test(f.replace(/\\/g, '/')));
|
|
45
|
+
if (hasGoTalkPlists)
|
|
46
|
+
return 'gotalknow';
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
/* not a readable zip — fall through to extension-based detection */
|
|
50
|
+
}
|
|
51
|
+
}
|
|
31
52
|
// Otherwise use file extension
|
|
32
53
|
return (0, io_1.extname)(filePath).slice(1);
|
|
33
54
|
}
|
|
@@ -438,6 +459,64 @@ commander_1.program
|
|
|
438
459
|
process.exit(1);
|
|
439
460
|
}
|
|
440
461
|
});
|
|
462
|
+
commander_1.program
|
|
463
|
+
.command('vocabulary <file>')
|
|
464
|
+
.description('Counts-only vocabulary inventory: buttons, wordlists, prediction dictionaries and word forms (no word lists are emitted)')
|
|
465
|
+
.option('--format <format>', 'Format type (auto-detected if not specified)')
|
|
466
|
+
.option('--out <path>', 'Write output to a file instead of stdout')
|
|
467
|
+
.option('--preserve-all-buttons', 'Preserve all buttons including navigation/system buttons')
|
|
468
|
+
.option('--no-exclude-navigation', "Don't exclude navigation buttons (Home, Back)")
|
|
469
|
+
.option('--no-exclude-system', "Don't exclude system buttons (Delete, Clear, etc.)")
|
|
470
|
+
.option('--exclude-buttons <list>', 'Comma-separated list of button labels/terms to exclude')
|
|
471
|
+
.option('--gridset-password <password>', 'Password for encrypted Grid3 archives (.gridsetx)')
|
|
472
|
+
.option('--no-smart-grammar', 'Skip smart-grammar word-form counts')
|
|
473
|
+
.action(async (file, options) => {
|
|
474
|
+
try {
|
|
475
|
+
const filteringOptions = parseFilteringOptions(options);
|
|
476
|
+
const format = options.format || (await detectFormat(file));
|
|
477
|
+
const processor = (0, analyze_1.getProcessor)(format, filteringOptions);
|
|
478
|
+
// The gridset loader logs debug chatter to stdout; silence it so the
|
|
479
|
+
// command's stdout is exactly one JSON document.
|
|
480
|
+
const silenced = Object.entries(console).reduce((acc, [k, fn]) => {
|
|
481
|
+
acc[k] = fn;
|
|
482
|
+
return acc;
|
|
483
|
+
}, {});
|
|
484
|
+
for (const k of ['log', 'info', 'debug', 'warn']) {
|
|
485
|
+
console[k] = () => { };
|
|
486
|
+
}
|
|
487
|
+
let dump;
|
|
488
|
+
try {
|
|
489
|
+
const tree = await processor.loadIntoTree(file);
|
|
490
|
+
// analyze() first: it expands morphological predictions on the tree,
|
|
491
|
+
// which dumpVocabulary() needs for the word-form counts.
|
|
492
|
+
const metrics = new analytics_1.MetricsCalculator().analyze(tree, {
|
|
493
|
+
useSmartGrammar: options.smartGrammar,
|
|
494
|
+
});
|
|
495
|
+
dump = (0, analytics_1.dumpVocabulary)(tree, { metrics });
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
for (const [k, fn] of Object.entries(silenced)) {
|
|
499
|
+
console[k] = fn;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const result = {
|
|
503
|
+
format,
|
|
504
|
+
filtering: filteringOptions,
|
|
505
|
+
vocabulary: dump,
|
|
506
|
+
};
|
|
507
|
+
const output = JSON.stringify(result, null, 2);
|
|
508
|
+
if (options.out) {
|
|
509
|
+
await writeTextToPath(options.out, output);
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
console.log(output);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
console.error('Error dumping vocabulary:', error instanceof Error ? error.message : String(error));
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
});
|
|
441
520
|
// Show help if no command provided
|
|
442
521
|
if (process.argv.length <= 2) {
|
|
443
522
|
commander_1.program.help();
|
package/dist/core/analyze.js
CHANGED
|
@@ -49,6 +49,7 @@ function getProcessor(format, options) {
|
|
|
49
49
|
case 'gotalknow':
|
|
50
50
|
case 'gotalk':
|
|
51
51
|
case 'gtbz': // GoTalk NOW file extension
|
|
52
|
+
case 'gotalk-book': // GoTalk NOW share export (e.g. "MyBook.gotalk-book.zip")
|
|
52
53
|
return new gotalkNowProcessor_1.GotalkNowProcessor(options);
|
|
53
54
|
default:
|
|
54
55
|
throw new Error('Unknown format: ' + format);
|
package/dist/index.browser.js
CHANGED
|
@@ -96,6 +96,12 @@ function getProcessor(filePathOrExtension, options) {
|
|
|
96
96
|
const extension = filePathOrExtension.includes('.')
|
|
97
97
|
? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.'))
|
|
98
98
|
: filePathOrExtension;
|
|
99
|
+
// GoTalk NOW share exports keep their book suffix before the archive
|
|
100
|
+
// extension (e.g. "MyBook.gotalk-book.zip"), so match on the name, not the
|
|
101
|
+
// final extension.
|
|
102
|
+
if (/\.gotalk-book(\.|$)/i.test(filePathOrExtension)) {
|
|
103
|
+
return new gotalkNowProcessor_2.GotalkNowProcessor(options);
|
|
104
|
+
}
|
|
99
105
|
switch (extension.toLowerCase()) {
|
|
100
106
|
case '.dot':
|
|
101
107
|
return new dotProcessor_2.DotProcessor(options);
|
|
@@ -138,6 +144,7 @@ function getSupportedExtensions() {
|
|
|
138
144
|
'.plist',
|
|
139
145
|
'.grd',
|
|
140
146
|
'.gtbz',
|
|
147
|
+
'.gotalk-book',
|
|
141
148
|
];
|
|
142
149
|
}
|
|
143
150
|
/**
|
package/dist/index.node.js
CHANGED
|
@@ -102,6 +102,12 @@ function getProcessor(filePathOrExtension, options) {
|
|
|
102
102
|
const extension = filePathOrExtension.includes('.')
|
|
103
103
|
? filePathOrExtension.substring(filePathOrExtension.lastIndexOf('.'))
|
|
104
104
|
: filePathOrExtension;
|
|
105
|
+
// GoTalk NOW share exports keep their book suffix before the archive
|
|
106
|
+
// extension (e.g. "MyBook.gotalk-book.zip"), so match on the name, not the
|
|
107
|
+
// final extension.
|
|
108
|
+
if (/\.gotalk-book(\.|$)/i.test(filePathOrExtension)) {
|
|
109
|
+
return new gotalkNowProcessor_1.GotalkNowProcessor(options);
|
|
110
|
+
}
|
|
105
111
|
switch (extension.toLowerCase()) {
|
|
106
112
|
case '.dot':
|
|
107
113
|
return new dotProcessor_1.DotProcessor(options);
|
|
@@ -152,6 +158,7 @@ function getSupportedExtensions() {
|
|
|
152
158
|
'.plist',
|
|
153
159
|
'.grd',
|
|
154
160
|
'.gtbz',
|
|
161
|
+
'.gotalk-book',
|
|
155
162
|
];
|
|
156
163
|
}
|
|
157
164
|
/**
|
package/dist/metrics.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ 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';
|
|
14
|
+
export type { VocabularyDump, VocabularyDumpOptions, VocabularySourceCounts, VocabularySummary, } from './utilities/analytics/metrics/vocabularyDump';
|
|
13
15
|
export { SentenceAnalyzer } from './utilities/analytics/metrics/sentence';
|
|
14
16
|
export { ComparisonAnalyzer } from './utilities/analytics/metrics/comparison';
|
|
15
17
|
export { MorphologyEngine } from './utilities/analytics/morphology';
|