@willwade/aac-processors 0.2.5 → 0.2.7

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.
@@ -2204,6 +2204,130 @@ class GridsetProcessor extends BaseProcessor {
2204
2204
  RowDefinition: Array(maxRows).fill({}),
2205
2205
  };
2206
2206
  }
2207
+ /**
2208
+ * Save a modified tree while preserving all original files (settings, images, assets)
2209
+ * This method only updates the grid.xml files for pages in the tree, keeping everything else intact.
2210
+ *
2211
+ * @param originalPath - Path to the original gridset file
2212
+ * @param tree - Modified AACTree with pages to save
2213
+ * @param outputPath - Path where the modified gridset should be saved
2214
+ */
2215
+ async saveModifiedTree(originalPath, tree, outputPath) {
2216
+ const { readBinaryFromInput, writeBinaryToPath } = this.options.fileAdapter;
2217
+ if (Object.keys(tree.pages).length === 0) {
2218
+ // Empty tree, just copy the original
2219
+ const originalBuffer = await readBinaryFromInput(originalPath);
2220
+ await writeBinaryToPath(outputPath, originalBuffer);
2221
+ return;
2222
+ }
2223
+ const AdmZip = (await import('adm-zip')).default;
2224
+ const originalZip = new AdmZip(originalPath);
2225
+ const outputZip = new AdmZip();
2226
+ // Collect styles from the tree for grid.xml files
2227
+ const uniqueStyles = new Map();
2228
+ let styleIdCounter = 1;
2229
+ const addStyle = (style) => {
2230
+ if (!style)
2231
+ return '';
2232
+ const normalizedStyle = { ...style };
2233
+ const styleKey = JSON.stringify(normalizedStyle);
2234
+ const existing = uniqueStyles.get(styleKey);
2235
+ if (existing)
2236
+ return existing.id;
2237
+ const styleId = `Style${styleIdCounter++}`;
2238
+ uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle });
2239
+ return styleId;
2240
+ };
2241
+ // Collect all styles from pages and buttons
2242
+ Object.values(tree.pages).forEach((page) => {
2243
+ addStyle(page.style);
2244
+ page.buttons.forEach((button) => {
2245
+ addStyle(button.style);
2246
+ });
2247
+ });
2248
+ // Track which grid files we're modifying
2249
+ const modifiedGridFiles = new Set();
2250
+ // Generate grid.xml files for pages in the tree
2251
+ const newGridFiles = new Map();
2252
+ for (const page of Object.values(tree.pages)) {
2253
+ const gridPath = `Grids/${page.name}/grid.xml`;
2254
+ modifiedGridFiles.add(gridPath);
2255
+ // Build the grid XML content
2256
+ const gridData = {
2257
+ Grid: {
2258
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2259
+ GridGuid: page.id,
2260
+ ColumnDefinitions: this.calculateColumnDefinitions(page),
2261
+ RowDefinitions: this.calculateRowDefinitions(page, false),
2262
+ AutoContentCommands: '',
2263
+ Cells: page.buttons.length > 0
2264
+ ? {
2265
+ Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2266
+ const buttonStyleId = button.style ? addStyle(button.style) : '';
2267
+ const position = this.findButtonPosition(page, button, btnIndex);
2268
+ const captionAndImage = {
2269
+ Caption: button.label || '',
2270
+ };
2271
+ // Handle image references
2272
+ if (button.image) {
2273
+ captionAndImage.Image = `${button.image}`;
2274
+ }
2275
+ const cell = {
2276
+ '@_Column': position.x,
2277
+ '@_Row': position.y,
2278
+ captionAndImage,
2279
+ };
2280
+ if (position.columnSpan > 1) {
2281
+ cell['@_ColumnSpan'] = position.columnSpan;
2282
+ }
2283
+ if (position.rowSpan > 1) {
2284
+ cell['@_RowSpan'] = position.rowSpan;
2285
+ }
2286
+ if (buttonStyleId) {
2287
+ cell.CellStyle = buttonStyleId;
2288
+ }
2289
+ if (button.message && button.message !== button.label) {
2290
+ // Use spoken message if different from label
2291
+ const spoken = button.message;
2292
+ const cellContent = {
2293
+ spoken,
2294
+ type: 'text',
2295
+ };
2296
+ cell['ContentCell'] = cellContent;
2297
+ }
2298
+ return cell;
2299
+ }),
2300
+ }
2301
+ : undefined,
2302
+ },
2303
+ };
2304
+ const gridBuilder = new XMLBuilder({
2305
+ ignoreAttributes: false,
2306
+ format: true,
2307
+ indentBy: ' ',
2308
+ suppressEmptyNode: true,
2309
+ });
2310
+ newGridFiles.set(gridPath, gridBuilder.build(gridData));
2311
+ }
2312
+ // Copy all files from original zip, replacing modified grid files
2313
+ for (const entry of originalZip.getEntries()) {
2314
+ if (entry.isDirectory)
2315
+ continue;
2316
+ // Skip grid.xml files that we're modifying
2317
+ if (modifiedGridFiles.has(entry.entryName)) {
2318
+ const newContent = newGridFiles.get(entry.entryName);
2319
+ if (newContent) {
2320
+ outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
2321
+ }
2322
+ continue;
2323
+ }
2324
+ // Copy all other files as-is
2325
+ outputZip.addFile(entry.entryName, entry.getData());
2326
+ }
2327
+ // Write the output ZIP
2328
+ const outputBuffer = outputZip.toBuffer();
2329
+ await writeBinaryToPath(outputPath, outputBuffer);
2330
+ }
2207
2331
  // Helper method to find button position with span information
2208
2332
  findButtonPosition(page, button, fallbackIndex) {
2209
2333
  if (page.grid && page.grid.length > 0) {
@@ -616,6 +616,75 @@ class ObfProcessor extends BaseProcessor {
616
616
  await writeBinaryToPath(outputPath, zipData);
617
617
  }
618
618
  }
619
+ /**
620
+ * Save a modified tree while preserving all original files (images, sounds, assets)
621
+ * This method only updates the .obf files for pages in the tree, keeping everything else intact.
622
+ *
623
+ * @param originalPath - Path to the original OBF/OBZ file
624
+ * @param tree - Modified AACTree with pages to save
625
+ * @param outputPath - Path where the modified file should be saved
626
+ */
627
+ async saveModifiedTree(originalPath, tree, outputPath) {
628
+ const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter;
629
+ // If output is .obf (single file), use regular save
630
+ if (outputPath.endsWith('.obf')) {
631
+ await this.saveFromTree(tree, outputPath);
632
+ return;
633
+ }
634
+ if (Object.keys(tree.pages).length === 0) {
635
+ // Empty tree, just copy the original
636
+ const originalBuffer = await readBinaryFromInput(originalPath);
637
+ await writeBinaryToPath(outputPath, originalBuffer);
638
+ return;
639
+ }
640
+ const AdmZip = (await import('adm-zip')).default;
641
+ const originalZip = new AdmZip(originalPath);
642
+ const outputZip = new AdmZip();
643
+ const getPageFilename = (id) => (id.endsWith('.obf') ? id : `${id}.obf`);
644
+ // Track which .obf files we're modifying
645
+ const modifiedObfFiles = new Set();
646
+ // Generate new .obf files for pages in the tree
647
+ const newObfFiles = new Map();
648
+ for (const page of Object.values(tree.pages)) {
649
+ const obfFilename = getPageFilename(page.id);
650
+ modifiedObfFiles.add(obfFilename);
651
+ const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
652
+ const obfContent = JSON.stringify(obfBoard, null, 2);
653
+ newObfFiles.set(obfFilename, obfContent);
654
+ }
655
+ // Generate updated manifest if we have pages
656
+ if (Object.keys(tree.pages).length > 0) {
657
+ modifiedObfFiles.add('manifest.json');
658
+ const manifest = {
659
+ format: OBF_FORMAT_VERSION,
660
+ root: tree.metadata.defaultHomePageId,
661
+ paths: {
662
+ boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])),
663
+ images: {},
664
+ sounds: {},
665
+ },
666
+ };
667
+ newObfFiles.set('manifest.json', JSON.stringify(manifest));
668
+ }
669
+ // Copy all files from original zip, replacing modified .obf files
670
+ for (const entry of originalZip.getEntries()) {
671
+ if (entry.isDirectory)
672
+ continue;
673
+ // Skip .obf files that we're modifying
674
+ if (modifiedObfFiles.has(entry.entryName)) {
675
+ const newContent = newObfFiles.get(entry.entryName);
676
+ if (newContent) {
677
+ outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
678
+ }
679
+ continue;
680
+ }
681
+ // Copy all other files as-is (preserves images, sounds, etc.)
682
+ outputZip.addFile(entry.entryName, entry.getData());
683
+ }
684
+ // Write the output ZIP
685
+ const outputBuffer = outputZip.toBuffer();
686
+ await writeBinaryToPath(outputPath, outputBuffer);
687
+ }
619
688
  /**
620
689
  * Extract strings with metadata for aac-tools-platform compatibility
621
690
  * Uses the generic implementation from BaseProcessor
@@ -804,6 +804,10 @@ export class MetricsCalculator {
804
804
  }
805
805
  if (!parentMetrics)
806
806
  return;
807
+ // Build set of original Suggest Words predictions (from Prediction.PredictThis).
808
+ // These require an extra confirmation tap from the user. Smart grammar
809
+ // morphology outcomes are generated automatically and need no extra tap.
810
+ const suggestWordsSet = new Set((btn.parameters?.predictions || []).map((w) => w.toLowerCase()));
807
811
  // Calculate effort for each word form
808
812
  btn.predictions.forEach((wordForm, index) => {
809
813
  const wordFormLower = wordForm.toLowerCase();
@@ -816,8 +820,14 @@ export class MetricsCalculator {
816
820
  // Using similar logic to button scanning effort
817
821
  const predictionPriorItems = predictionRowIndex * predictionsGridCols + predictionColIndex;
818
822
  const predictionSelectionEffort = visualScanEffort(predictionPriorItems);
819
- // Word form effort = parent button's cumulative effort + selection effort
820
- const wordFormEffort = parentMetrics.effort + predictionSelectionEffort;
823
+ // Add confirmation cost for Suggest Words outcomes only.
824
+ // Suggest Words requires an explicit tap on the prediction bar,
825
+ // while smart grammar morphology forms are auto-generated (no extra tap).
826
+ const suggestWordsConfirmation = suggestWordsSet.has(wordFormLower)
827
+ ? EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT
828
+ : 0;
829
+ // Word form effort = parent button's cumulative effort + selection effort + confirmation
830
+ const wordFormEffort = parentMetrics.effort + predictionSelectionEffort + suggestWordsConfirmation;
821
831
  // Check if this word already exists as a regular button
822
832
  const existingBtn = existingLabels.get(wordFormLower);
823
833
  // If word exists and has lower or equal effort, skip the word form
@@ -838,9 +848,10 @@ export class MetricsCalculator {
838
848
  semantic_id: parentMetrics.semantic_id,
839
849
  clone_id: parentMetrics.clone_id,
840
850
  temporary_home_id: parentMetrics.temporary_home_id,
841
- is_word_form: true, // Mark this as a word form metric
842
- parent_button_id: btn.id, // Track parent button
843
- parent_button_label: parentMetrics.label, // Track parent label
851
+ is_word_form: true,
852
+ is_suggest_words: suggestWordsSet.has(wordFormLower) || undefined,
853
+ parent_button_id: btn.id,
854
+ parent_button_label: parentMetrics.label,
844
855
  };
845
856
  wordFormMetrics.push(wordFormBtn);
846
857
  existingLabels.set(wordFormLower, wordFormBtn);
@@ -31,6 +31,7 @@ export const EFFORT_CONSTANTS = {
31
31
  SCAN_SELECTION_COST: 0.1, // Cost of a switch selection
32
32
  DEFAULT_SCAN_ERROR_RATE: 0.1, // 10% chance of missing a selection
33
33
  SCAN_RETRY_PENALTY: 1.0, // Cost multiplier for a full loop retry
34
+ SUGGEST_WORDS_SELECTION_EFFORT: 0.5, // Extra tap to confirm a Suggest Words prediction
34
35
  };
35
36
  /**
36
37
  * Calculate button size effort based on grid dimensions
@@ -51,6 +51,15 @@ declare class GridsetProcessor extends BaseProcessor {
51
51
  saveFromTree(tree: AACTree, outputPath: string): Promise<void>;
52
52
  private calculateColumnDefinitions;
53
53
  private calculateRowDefinitions;
54
+ /**
55
+ * Save a modified tree while preserving all original files (settings, images, assets)
56
+ * This method only updates the grid.xml files for pages in the tree, keeping everything else intact.
57
+ *
58
+ * @param originalPath - Path to the original gridset file
59
+ * @param tree - Modified AACTree with pages to save
60
+ * @param outputPath - Path where the modified gridset should be saved
61
+ */
62
+ saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void>;
54
63
  private findButtonPosition;
55
64
  /**
56
65
  * Extract strings with metadata for aac-tools-platform compatibility
@@ -1,4 +1,27 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
2
25
  Object.defineProperty(exports, "__esModule", { value: true });
3
26
  exports.GridsetProcessor = void 0;
4
27
  const baseProcessor_1 = require("../core/baseProcessor");
@@ -2207,6 +2230,130 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2207
2230
  RowDefinition: Array(maxRows).fill({}),
2208
2231
  };
2209
2232
  }
2233
+ /**
2234
+ * Save a modified tree while preserving all original files (settings, images, assets)
2235
+ * This method only updates the grid.xml files for pages in the tree, keeping everything else intact.
2236
+ *
2237
+ * @param originalPath - Path to the original gridset file
2238
+ * @param tree - Modified AACTree with pages to save
2239
+ * @param outputPath - Path where the modified gridset should be saved
2240
+ */
2241
+ async saveModifiedTree(originalPath, tree, outputPath) {
2242
+ const { readBinaryFromInput, writeBinaryToPath } = this.options.fileAdapter;
2243
+ if (Object.keys(tree.pages).length === 0) {
2244
+ // Empty tree, just copy the original
2245
+ const originalBuffer = await readBinaryFromInput(originalPath);
2246
+ await writeBinaryToPath(outputPath, originalBuffer);
2247
+ return;
2248
+ }
2249
+ const AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
2250
+ const originalZip = new AdmZip(originalPath);
2251
+ const outputZip = new AdmZip();
2252
+ // Collect styles from the tree for grid.xml files
2253
+ const uniqueStyles = new Map();
2254
+ let styleIdCounter = 1;
2255
+ const addStyle = (style) => {
2256
+ if (!style)
2257
+ return '';
2258
+ const normalizedStyle = { ...style };
2259
+ const styleKey = JSON.stringify(normalizedStyle);
2260
+ const existing = uniqueStyles.get(styleKey);
2261
+ if (existing)
2262
+ return existing.id;
2263
+ const styleId = `Style${styleIdCounter++}`;
2264
+ uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle });
2265
+ return styleId;
2266
+ };
2267
+ // Collect all styles from pages and buttons
2268
+ Object.values(tree.pages).forEach((page) => {
2269
+ addStyle(page.style);
2270
+ page.buttons.forEach((button) => {
2271
+ addStyle(button.style);
2272
+ });
2273
+ });
2274
+ // Track which grid files we're modifying
2275
+ const modifiedGridFiles = new Set();
2276
+ // Generate grid.xml files for pages in the tree
2277
+ const newGridFiles = new Map();
2278
+ for (const page of Object.values(tree.pages)) {
2279
+ const gridPath = `Grids/${page.name}/grid.xml`;
2280
+ modifiedGridFiles.add(gridPath);
2281
+ // Build the grid XML content
2282
+ const gridData = {
2283
+ Grid: {
2284
+ '@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2285
+ GridGuid: page.id,
2286
+ ColumnDefinitions: this.calculateColumnDefinitions(page),
2287
+ RowDefinitions: this.calculateRowDefinitions(page, false),
2288
+ AutoContentCommands: '',
2289
+ Cells: page.buttons.length > 0
2290
+ ? {
2291
+ Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2292
+ const buttonStyleId = button.style ? addStyle(button.style) : '';
2293
+ const position = this.findButtonPosition(page, button, btnIndex);
2294
+ const captionAndImage = {
2295
+ Caption: button.label || '',
2296
+ };
2297
+ // Handle image references
2298
+ if (button.image) {
2299
+ captionAndImage.Image = `${button.image}`;
2300
+ }
2301
+ const cell = {
2302
+ '@_Column': position.x,
2303
+ '@_Row': position.y,
2304
+ captionAndImage,
2305
+ };
2306
+ if (position.columnSpan > 1) {
2307
+ cell['@_ColumnSpan'] = position.columnSpan;
2308
+ }
2309
+ if (position.rowSpan > 1) {
2310
+ cell['@_RowSpan'] = position.rowSpan;
2311
+ }
2312
+ if (buttonStyleId) {
2313
+ cell.CellStyle = buttonStyleId;
2314
+ }
2315
+ if (button.message && button.message !== button.label) {
2316
+ // Use spoken message if different from label
2317
+ const spoken = button.message;
2318
+ const cellContent = {
2319
+ spoken,
2320
+ type: 'text',
2321
+ };
2322
+ cell['ContentCell'] = cellContent;
2323
+ }
2324
+ return cell;
2325
+ }),
2326
+ }
2327
+ : undefined,
2328
+ },
2329
+ };
2330
+ const gridBuilder = new fast_xml_parser_1.XMLBuilder({
2331
+ ignoreAttributes: false,
2332
+ format: true,
2333
+ indentBy: ' ',
2334
+ suppressEmptyNode: true,
2335
+ });
2336
+ newGridFiles.set(gridPath, gridBuilder.build(gridData));
2337
+ }
2338
+ // Copy all files from original zip, replacing modified grid files
2339
+ for (const entry of originalZip.getEntries()) {
2340
+ if (entry.isDirectory)
2341
+ continue;
2342
+ // Skip grid.xml files that we're modifying
2343
+ if (modifiedGridFiles.has(entry.entryName)) {
2344
+ const newContent = newGridFiles.get(entry.entryName);
2345
+ if (newContent) {
2346
+ outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
2347
+ }
2348
+ continue;
2349
+ }
2350
+ // Copy all other files as-is
2351
+ outputZip.addFile(entry.entryName, entry.getData());
2352
+ }
2353
+ // Write the output ZIP
2354
+ const outputBuffer = outputZip.toBuffer();
2355
+ await writeBinaryToPath(outputPath, outputBuffer);
2356
+ }
2210
2357
  // Helper method to find button position with span information
2211
2358
  findButtonPosition(page, button, fallbackIndex) {
2212
2359
  if (page.grid && page.grid.length > 0) {
@@ -23,6 +23,15 @@ declare class ObfProcessor extends BaseProcessor {
23
23
  private createObfBoardFromPage;
24
24
  processTexts(filePathOrBuffer: ProcessorInput, translations: Map<string, string>, outputPath: string): Promise<Uint8Array>;
25
25
  saveFromTree(tree: AACTree, outputPath: string): Promise<void>;
26
+ /**
27
+ * Save a modified tree while preserving all original files (images, sounds, assets)
28
+ * This method only updates the .obf files for pages in the tree, keeping everything else intact.
29
+ *
30
+ * @param originalPath - Path to the original OBF/OBZ file
31
+ * @param tree - Modified AACTree with pages to save
32
+ * @param outputPath - Path where the modified file should be saved
33
+ */
34
+ saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void>;
26
35
  /**
27
36
  * Extract strings with metadata for aac-tools-platform compatibility
28
37
  * Uses the generic implementation from BaseProcessor
@@ -1,4 +1,27 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
2
25
  Object.defineProperty(exports, "__esModule", { value: true });
3
26
  exports.ObfProcessor = void 0;
4
27
  const baseProcessor_1 = require("../core/baseProcessor");
@@ -619,6 +642,75 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
619
642
  await writeBinaryToPath(outputPath, zipData);
620
643
  }
621
644
  }
645
+ /**
646
+ * Save a modified tree while preserving all original files (images, sounds, assets)
647
+ * This method only updates the .obf files for pages in the tree, keeping everything else intact.
648
+ *
649
+ * @param originalPath - Path to the original OBF/OBZ file
650
+ * @param tree - Modified AACTree with pages to save
651
+ * @param outputPath - Path where the modified file should be saved
652
+ */
653
+ async saveModifiedTree(originalPath, tree, outputPath) {
654
+ const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter;
655
+ // If output is .obf (single file), use regular save
656
+ if (outputPath.endsWith('.obf')) {
657
+ await this.saveFromTree(tree, outputPath);
658
+ return;
659
+ }
660
+ if (Object.keys(tree.pages).length === 0) {
661
+ // Empty tree, just copy the original
662
+ const originalBuffer = await readBinaryFromInput(originalPath);
663
+ await writeBinaryToPath(outputPath, originalBuffer);
664
+ return;
665
+ }
666
+ const AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
667
+ const originalZip = new AdmZip(originalPath);
668
+ const outputZip = new AdmZip();
669
+ const getPageFilename = (id) => (id.endsWith('.obf') ? id : `${id}.obf`);
670
+ // Track which .obf files we're modifying
671
+ const modifiedObfFiles = new Set();
672
+ // Generate new .obf files for pages in the tree
673
+ const newObfFiles = new Map();
674
+ for (const page of Object.values(tree.pages)) {
675
+ const obfFilename = getPageFilename(page.id);
676
+ modifiedObfFiles.add(obfFilename);
677
+ const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
678
+ const obfContent = JSON.stringify(obfBoard, null, 2);
679
+ newObfFiles.set(obfFilename, obfContent);
680
+ }
681
+ // Generate updated manifest if we have pages
682
+ if (Object.keys(tree.pages).length > 0) {
683
+ modifiedObfFiles.add('manifest.json');
684
+ const manifest = {
685
+ format: OBF_FORMAT_VERSION,
686
+ root: tree.metadata.defaultHomePageId,
687
+ paths: {
688
+ boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])),
689
+ images: {},
690
+ sounds: {},
691
+ },
692
+ };
693
+ newObfFiles.set('manifest.json', JSON.stringify(manifest));
694
+ }
695
+ // Copy all files from original zip, replacing modified .obf files
696
+ for (const entry of originalZip.getEntries()) {
697
+ if (entry.isDirectory)
698
+ continue;
699
+ // Skip .obf files that we're modifying
700
+ if (modifiedObfFiles.has(entry.entryName)) {
701
+ const newContent = newObfFiles.get(entry.entryName);
702
+ if (newContent) {
703
+ outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
704
+ }
705
+ continue;
706
+ }
707
+ // Copy all other files as-is (preserves images, sounds, etc.)
708
+ outputZip.addFile(entry.entryName, entry.getData());
709
+ }
710
+ // Write the output ZIP
711
+ const outputBuffer = outputZip.toBuffer();
712
+ await writeBinaryToPath(outputPath, outputBuffer);
713
+ }
622
714
  /**
623
715
  * Extract strings with metadata for aac-tools-platform compatibility
624
716
  * Uses the generic implementation from BaseProcessor
@@ -807,6 +807,10 @@ class MetricsCalculator {
807
807
  }
808
808
  if (!parentMetrics)
809
809
  return;
810
+ // Build set of original Suggest Words predictions (from Prediction.PredictThis).
811
+ // These require an extra confirmation tap from the user. Smart grammar
812
+ // morphology outcomes are generated automatically and need no extra tap.
813
+ const suggestWordsSet = new Set((btn.parameters?.predictions || []).map((w) => w.toLowerCase()));
810
814
  // Calculate effort for each word form
811
815
  btn.predictions.forEach((wordForm, index) => {
812
816
  const wordFormLower = wordForm.toLowerCase();
@@ -819,8 +823,14 @@ class MetricsCalculator {
819
823
  // Using similar logic to button scanning effort
820
824
  const predictionPriorItems = predictionRowIndex * predictionsGridCols + predictionColIndex;
821
825
  const predictionSelectionEffort = (0, effort_1.visualScanEffort)(predictionPriorItems);
822
- // Word form effort = parent button's cumulative effort + selection effort
823
- const wordFormEffort = parentMetrics.effort + predictionSelectionEffort;
826
+ // Add confirmation cost for Suggest Words outcomes only.
827
+ // Suggest Words requires an explicit tap on the prediction bar,
828
+ // while smart grammar morphology forms are auto-generated (no extra tap).
829
+ const suggestWordsConfirmation = suggestWordsSet.has(wordFormLower)
830
+ ? effort_1.EFFORT_CONSTANTS.SUGGEST_WORDS_SELECTION_EFFORT
831
+ : 0;
832
+ // Word form effort = parent button's cumulative effort + selection effort + confirmation
833
+ const wordFormEffort = parentMetrics.effort + predictionSelectionEffort + suggestWordsConfirmation;
824
834
  // Check if this word already exists as a regular button
825
835
  const existingBtn = existingLabels.get(wordFormLower);
826
836
  // If word exists and has lower or equal effort, skip the word form
@@ -841,9 +851,10 @@ class MetricsCalculator {
841
851
  semantic_id: parentMetrics.semantic_id,
842
852
  clone_id: parentMetrics.clone_id,
843
853
  temporary_home_id: parentMetrics.temporary_home_id,
844
- is_word_form: true, // Mark this as a word form metric
845
- parent_button_id: btn.id, // Track parent button
846
- parent_button_label: parentMetrics.label, // Track parent label
854
+ is_word_form: true,
855
+ is_suggest_words: suggestWordsSet.has(wordFormLower) || undefined,
856
+ parent_button_id: btn.id,
857
+ parent_button_label: parentMetrics.label,
847
858
  };
848
859
  wordFormMetrics.push(wordFormBtn);
849
860
  existingLabels.set(wordFormLower, wordFormBtn);
@@ -31,6 +31,7 @@ export declare const EFFORT_CONSTANTS: {
31
31
  readonly SCAN_SELECTION_COST: 0.1;
32
32
  readonly DEFAULT_SCAN_ERROR_RATE: 0.1;
33
33
  readonly SCAN_RETRY_PENALTY: 1;
34
+ readonly SUGGEST_WORDS_SELECTION_EFFORT: 0.5;
34
35
  };
35
36
  /**
36
37
  * Calculate button size effort based on grid dimensions
@@ -47,6 +47,7 @@ exports.EFFORT_CONSTANTS = {
47
47
  SCAN_SELECTION_COST: 0.1, // Cost of a switch selection
48
48
  DEFAULT_SCAN_ERROR_RATE: 0.1, // 10% chance of missing a selection
49
49
  SCAN_RETRY_PENALTY: 1.0, // Cost multiplier for a full loop retry
50
+ SUGGEST_WORDS_SELECTION_EFFORT: 0.5, // Extra tap to confirm a Suggest Words prediction
50
51
  };
51
52
  /**
52
53
  * Calculate button size effort based on grid dimensions
@@ -19,6 +19,7 @@ export interface ButtonMetrics {
19
19
  comp_level?: number;
20
20
  comp_effort?: number;
21
21
  is_word_form?: boolean;
22
+ is_suggest_words?: boolean;
22
23
  parent_button_id?: string;
23
24
  parent_button_label?: string;
24
25
  pos?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@willwade/aac-processors",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "A comprehensive TypeScript library for processing AAC (Augmentative and Alternative Communication) file formats with translation support",
5
5
  "main": "dist/index.js",
6
6
  "browser": "dist/browser/index.browser.js",