@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.
- package/dist/browser/processors/gridsetProcessor.js +124 -0
- package/dist/browser/processors/obfProcessor.js +69 -0
- package/dist/browser/utilities/analytics/metrics/core.js +16 -5
- package/dist/browser/utilities/analytics/metrics/effort.js +1 -0
- package/dist/processors/gridsetProcessor.d.ts +9 -0
- package/dist/processors/gridsetProcessor.js +147 -0
- package/dist/processors/obfProcessor.d.ts +9 -0
- package/dist/processors/obfProcessor.js +92 -0
- package/dist/utilities/analytics/metrics/core.js +16 -5
- package/dist/utilities/analytics/metrics/effort.d.ts +1 -0
- package/dist/utilities/analytics/metrics/effort.js +1 -0
- package/dist/utilities/analytics/metrics/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
820
|
-
|
|
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,
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
//
|
|
823
|
-
|
|
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,
|
|
845
|
-
|
|
846
|
-
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@willwade/aac-processors",
|
|
3
|
-
"version": "0.2.
|
|
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",
|