@willwade/aac-processors 0.2.12 → 0.2.14

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.
Files changed (37) hide show
  1. package/dist/browser/core/treeStructure.js +60 -0
  2. package/dist/browser/processors/applePanelsProcessor.js +7 -1
  3. package/dist/browser/processors/astericsGridProcessor.js +7 -1
  4. package/dist/browser/processors/dotProcessor.js +9 -2
  5. package/dist/browser/processors/gridset/saveMutations.js +212 -0
  6. package/dist/browser/processors/gridsetProcessor.js +37 -39
  7. package/dist/browser/processors/obfProcessor.js +51 -1
  8. package/dist/browser/processors/opmlProcessor.js +7 -1
  9. package/dist/browser/processors/snapProcessor.js +7 -1
  10. package/dist/browser/processors/touchchatProcessor.js +8 -3
  11. package/dist/core/baseProcessor.d.ts +2 -0
  12. package/dist/core/treeStructure.d.ts +43 -2
  13. package/dist/core/treeStructure.js +60 -0
  14. package/dist/processors/applePanelsProcessor.d.ts +5 -0
  15. package/dist/processors/applePanelsProcessor.js +7 -1
  16. package/dist/processors/astericsGridProcessor.d.ts +5 -0
  17. package/dist/processors/astericsGridProcessor.js +7 -1
  18. package/dist/processors/dotProcessor.d.ts +5 -0
  19. package/dist/processors/dotProcessor.js +9 -2
  20. package/dist/processors/excelProcessor.d.ts +5 -0
  21. package/dist/processors/excelProcessor.js +8 -0
  22. package/dist/processors/gridset/saveMutations.d.ts +39 -0
  23. package/dist/processors/gridset/saveMutations.js +216 -0
  24. package/dist/processors/gridsetProcessor.d.ts +5 -0
  25. package/dist/processors/gridsetProcessor.js +37 -39
  26. package/dist/processors/obfProcessor.d.ts +14 -0
  27. package/dist/processors/obfProcessor.js +51 -1
  28. package/dist/processors/obfsetProcessor.d.ts +5 -0
  29. package/dist/processors/obfsetProcessor.js +5 -0
  30. package/dist/processors/opmlProcessor.d.ts +5 -0
  31. package/dist/processors/opmlProcessor.js +7 -1
  32. package/dist/processors/snapProcessor.d.ts +5 -0
  33. package/dist/processors/snapProcessor.js +7 -1
  34. package/dist/processors/touchchatProcessor.d.ts +5 -0
  35. package/dist/processors/touchchatProcessor.js +8 -3
  36. package/dist/types/aac.d.ts +54 -0
  37. package/package.json +1 -1
@@ -168,6 +168,8 @@ export class AACButton {
168
168
  }
169
169
  export class AACPage {
170
170
  constructor({ id, name = '', grid = [], buttons = [], parentId = null, style, locale, descriptionHtml, images, sounds, semantic_ids, clone_ids, scanningConfig, scanBlocksConfig, scanType, }) {
171
+ // Mutation tracking
172
+ this._pendingMutations = [];
171
173
  this.id = id;
172
174
  this.name = name;
173
175
  if (Array.isArray(grid)) {
@@ -196,6 +198,64 @@ export class AACPage {
196
198
  }
197
199
  addButton(button) {
198
200
  this.buttons.push(button);
201
+ // Record the mutation
202
+ this._pendingMutations.push({ type: 'addButton', button });
203
+ }
204
+ /**
205
+ * Internal load-path button push: adds a button to the page WITHOUT recording a mutation.
206
+ * Used by processors during loadIntoTree so the loaded baseline isn't treated as user changes.
207
+ * Not part of the public API — consumers should always use addButton.
208
+ */
209
+ _loadButton(button) {
210
+ this.buttons.push(button);
211
+ }
212
+ /**
213
+ * Discard all recorded mutations on this page.
214
+ * Useful as an escape hatch after loadIntoTree if the consumer wants a clean baseline.
215
+ */
216
+ clearMutations() {
217
+ this._pendingMutations = [];
218
+ }
219
+ /**
220
+ * Get the list of pending mutations for this page (read-only)
221
+ */
222
+ get pendingMutations() {
223
+ return Object.freeze([...this._pendingMutations]);
224
+ }
225
+ /**
226
+ * Remove a button by ID
227
+ * @param buttonId - The ID of the button to remove
228
+ */
229
+ removeButton(buttonId) {
230
+ this._pendingMutations.push({ type: 'removeButton', buttonId });
231
+ }
232
+ /**
233
+ * Update a button by merging a patch
234
+ * @param buttonId - The ID of the button to update
235
+ * @param patch - Partial button object with fields to update
236
+ */
237
+ updateButton(buttonId, patch) {
238
+ this._pendingMutations.push({ type: 'updateButton', buttonId, patch });
239
+ }
240
+ /**
241
+ * Add an item to the page's WordList (for formats with dynamic content cells)
242
+ * @param item - WordList item to add
243
+ */
244
+ addWordListItem(item) {
245
+ this._pendingMutations.push({ type: 'addWordListItem', item });
246
+ }
247
+ /**
248
+ * Remove items from the page's WordList
249
+ * @param textOrPredicate - Text to match or predicate function to filter items
250
+ */
251
+ removeWordListItem(textOrPredicate) {
252
+ this._pendingMutations.push({ type: 'removeWordListItem', match: textOrPredicate });
253
+ }
254
+ /**
255
+ * Clear all items from the page's WordList
256
+ */
257
+ clearWordList() {
258
+ this._pendingMutations.push({ type: 'clearWordList' });
199
259
  }
200
260
  }
201
261
  export class AACTree {
@@ -45,6 +45,11 @@ function normalizeActionParameters(input) {
45
45
  class ApplePanelsProcessor extends BaseProcessor {
46
46
  constructor(options) {
47
47
  super(options);
48
+ this.capabilities = {
49
+ wordList: 'none',
50
+ preservesAssetsOnSave: false,
51
+ newCellCreation: 'allowed',
52
+ };
48
53
  }
49
54
  // Helper function to parse Apple Panels Rect format "{{x, y}, {width, height}}"
50
55
  parseRect(rectString) {
@@ -214,7 +219,8 @@ class ApplePanelsProcessor extends BaseProcessor {
214
219
  fontWeight: btn.DisplayImageWeight === 'bold' ? 'bold' : 'normal',
215
220
  },
216
221
  });
217
- page.addButton(button);
222
+ // Load path: do not record as a user mutation
223
+ page._loadButton(button);
218
224
  if (btn.Rect) {
219
225
  const rect = this.parseRect(btn.Rect);
220
226
  if (rect) {
@@ -544,6 +544,11 @@ function mapAstericsVisibility(hidden) {
544
544
  class AstericsGridProcessor extends BaseProcessor {
545
545
  constructor(options = {}) {
546
546
  super(options);
547
+ this.capabilities = {
548
+ wordList: 'none',
549
+ preservesAssetsOnSave: false,
550
+ newCellCreation: 'allowed',
551
+ };
547
552
  this.loadAudio = false;
548
553
  this.loadAudio = options.loadAudio || false;
549
554
  }
@@ -709,7 +714,8 @@ class AstericsGridProcessor extends BaseProcessor {
709
714
  }
710
715
  grid.gridElements.forEach((element) => {
711
716
  const button = this.createButtonFromElement(element, colorConfig, activeColorSchemeDefinition);
712
- page.addButton(button);
717
+ // Load path: do not record as a user mutation
718
+ page._loadButton(button);
713
719
  const buttonX = element.x || 0;
714
720
  const buttonY = element.y || 0;
715
721
  const buttonWidth = element.width || 1;
@@ -5,6 +5,11 @@ import { getBasename, encodeText } from '../utils/io';
5
5
  class DotProcessor extends BaseProcessor {
6
6
  constructor(options) {
7
7
  super(options);
8
+ this.capabilities = {
9
+ wordList: 'none',
10
+ preservesAssetsOnSave: false,
11
+ newCellCreation: 'allowed',
12
+ };
8
13
  }
9
14
  parseDotFile(content) {
10
15
  const nodes = new Map();
@@ -112,7 +117,8 @@ class DotProcessor extends BaseProcessor {
112
117
  });
113
118
  tree.addPage(page);
114
119
  // Add a self button so single-node graphs yield one button
115
- page.addButton(new AACButton({
120
+ // Load path: do not record as a user mutation
121
+ page._loadButton(new AACButton({
116
122
  id: `${node.id}_self`,
117
123
  label: node.label,
118
124
  message: node.label,
@@ -133,7 +139,8 @@ class DotProcessor extends BaseProcessor {
133
139
  message: '',
134
140
  targetPageId: edge.to,
135
141
  });
136
- fromPage.addButton(button);
142
+ // Load path: do not record as a user mutation
143
+ fromPage._loadButton(button);
137
144
  }
138
145
  }
139
146
  return tree;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Gridset Save Mutations Module
3
+ *
4
+ * Handles saving AACTree mutations back to Gridset files.
5
+ * This module extracts the save logic from gridsetProcessor for better modularity.
6
+ */
7
+ import { formatGrid3XmlComplete } from './xmlFormatter';
8
+ export class GridsetSaveHandler {
9
+ constructor() {
10
+ // Dynamic imports for browser compatibility
11
+ }
12
+ /**
13
+ * Show deprecation warning for legacy save path
14
+ */
15
+ static warnLegacySave() {
16
+ const key = 'gridset_legacy_save_warned';
17
+ if (!global[key]) {
18
+ console.warn('saveModifiedTree: detected button changes without recorded mutations. ' +
19
+ 'This will continue to work in 0.x but is deprecated. ' +
20
+ 'Use page.addButton / page.addWordListItem to make changes explicit.');
21
+ global[key] = true;
22
+ }
23
+ }
24
+ /**
25
+ * Save using mutation-based logic
26
+ * Fixes bugs A, B, C by processing explicit mutations
27
+ */
28
+ static saveWithMutations(tree, originalZip, outputZip, parser, gridBuilder, createBasicGridXml) {
29
+ for (const page of Object.values(tree.pages)) {
30
+ // Skip pages with no mutations
31
+ if (page.pendingMutations.length === 0) {
32
+ continue;
33
+ }
34
+ const gridPath = `Grids/${page.name}/grid.xml`;
35
+ // Load or create grid.xml
36
+ const originalEntry = originalZip.getEntry(gridPath);
37
+ let originalGrid;
38
+ if (originalEntry) {
39
+ const originalContent = originalEntry.getData().toString('utf-8');
40
+ originalGrid = parser.parse(originalContent);
41
+ if (!originalGrid.Grid) {
42
+ originalGrid = null;
43
+ }
44
+ }
45
+ if (!originalGrid || !originalGrid.Grid) {
46
+ const basicGrid = createBasicGridXml(page);
47
+ const buffer = Buffer.from(basicGrid, 'utf8');
48
+ outputZip.addFile(gridPath, buffer);
49
+ continue;
50
+ }
51
+ // Index original cells by position
52
+ const cellsByPosition = new Map();
53
+ const cellArray = Array.isArray(originalGrid.Grid.Cells?.Cell)
54
+ ? originalGrid.Grid.Cells.Cell
55
+ : originalGrid.Grid.Cells?.Cell
56
+ ? [originalGrid.Grid.Cells.Cell]
57
+ : [];
58
+ for (const cell of cellArray) {
59
+ const x = cell['@_X'] !== undefined ? parseInt(String(cell['@_X']), 10) : undefined;
60
+ const y = parseInt(String(cell['@_Y'] || cell['@_Row'] || '0'), 10);
61
+ if (x !== undefined) {
62
+ cellsByPosition.set(`${x},${y}`, cell);
63
+ }
64
+ }
65
+ // Process mutations in order
66
+ for (const mutation of page.pendingMutations) {
67
+ switch (mutation.type) {
68
+ case 'addButton': {
69
+ const button = mutation.button;
70
+ const x = button.x ?? 0;
71
+ const y = button.y ?? 0;
72
+ const cell = cellsByPosition.get(`${x},${y}`);
73
+ if (cell && cell.Content) {
74
+ GridsetSaveHandler.applyButtonToCell(cell, button);
75
+ }
76
+ else {
77
+ // Bug C fix: warn instead of silently dropping
78
+ console.warn(`[Gridset] Cannot add button at (${x},${y}) - cell does not exist. ` +
79
+ `Use addWordListItem for dynamic content.`);
80
+ }
81
+ break;
82
+ }
83
+ case 'removeButton': {
84
+ const button = page.buttons.find((b) => b.id === mutation.buttonId);
85
+ if (button) {
86
+ const x = button.x ?? 0;
87
+ const y = button.y ?? 0;
88
+ const cell = cellsByPosition.get(`${x},${y}`);
89
+ if (cell && cell.Content) {
90
+ cell.Content.Visibility = 'Hidden';
91
+ }
92
+ }
93
+ break;
94
+ }
95
+ case 'updateButton': {
96
+ const button = page.buttons.find((b) => b.id === mutation.buttonId);
97
+ if (button) {
98
+ const x = button.x ?? 0;
99
+ const y = button.y ?? 0;
100
+ const cell = cellsByPosition.get(`${x},${y}`);
101
+ if (cell && cell.Content) {
102
+ GridsetSaveHandler.applyButtonToCell(cell, button, mutation.patch);
103
+ }
104
+ }
105
+ break;
106
+ }
107
+ case 'addWordListItem': {
108
+ GridsetSaveHandler.addWordListItemToGrid(originalGrid.Grid, mutation.item);
109
+ break;
110
+ }
111
+ case 'removeWordListItem': {
112
+ GridsetSaveHandler.removeWordListItemFromGrid(originalGrid.Grid, mutation.match);
113
+ break;
114
+ }
115
+ case 'clearWordList': {
116
+ if (originalGrid.Grid.WordList && originalGrid.Grid.WordList.Items) {
117
+ originalGrid.Grid.WordList.Items.WordListItem = [];
118
+ }
119
+ break;
120
+ }
121
+ }
122
+ }
123
+ // Build and write the updated grid XML
124
+ let builtXml = gridBuilder.build(originalGrid);
125
+ builtXml = formatGrid3XmlComplete(builtXml);
126
+ outputZip.addFile(gridPath, Buffer.from(builtXml, 'utf8'));
127
+ }
128
+ }
129
+ /**
130
+ * Apply button changes to a cell
131
+ */
132
+ static applyButtonToCell(cell, button, patch) {
133
+ const updates = patch ? { ...button, ...patch } : button;
134
+ const isPlaceholderLabel = !updates.label ||
135
+ updates.label.startsWith('Cell_') ||
136
+ updates.label.startsWith('AutoContent_') ||
137
+ updates.label.startsWith('Prediction ');
138
+ if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) {
139
+ const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage;
140
+ if (!isPlaceholderLabel && updates.label) {
141
+ captionAndImage.Caption = updates.label;
142
+ if (captionAndImage['@_xsi:nil'] || captionAndImage['xsi:nil']) {
143
+ delete captionAndImage['@_xsi:nil'];
144
+ delete captionAndImage['xsi:nil'];
145
+ }
146
+ }
147
+ if (updates.image) {
148
+ captionAndImage.Image = updates.image;
149
+ }
150
+ }
151
+ const isPlaceholderMessage = !updates.message ||
152
+ updates.message.startsWith('Cell_') ||
153
+ updates.message.startsWith('AutoContent_') ||
154
+ updates.message.startsWith('Prediction ');
155
+ if (!isPlaceholderMessage &&
156
+ updates.message &&
157
+ updates.message !== updates.label &&
158
+ !cell.Content.Commands) {
159
+ cell.Content['#text'] = updates.message;
160
+ }
161
+ }
162
+ /**
163
+ * Add an item to the WordList with de-duplication (Bug A fix)
164
+ */
165
+ static addWordListItemToGrid(grid, item) {
166
+ if (!grid.WordList) {
167
+ grid.WordList = {};
168
+ }
169
+ if (!grid.WordList.Items) {
170
+ grid.WordList.Items = {};
171
+ }
172
+ const existingItems = grid.WordList.Items.WordListItem || grid.WordList.Items.wordlistitem || [];
173
+ const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
174
+ // De-duplicate by text
175
+ const existingTexts = new Set(itemsArray
176
+ .map((item) => {
177
+ if (typeof item.Text === 'string')
178
+ return item.Text;
179
+ return item.Text?.p?.s?.r || '';
180
+ })
181
+ .filter(Boolean));
182
+ if (!existingTexts.has(item.text)) {
183
+ itemsArray.push({
184
+ Text: { p: { s: { r: item.text } } },
185
+ Image: item.image || '',
186
+ PartOfSpeech: item.partOfSpeech || 'Unknown',
187
+ });
188
+ grid.WordList.Items.WordListItem = itemsArray;
189
+ }
190
+ }
191
+ /**
192
+ * Remove items from the WordList
193
+ */
194
+ static removeWordListItemFromGrid(grid, match) {
195
+ if (!grid.WordList || !grid.WordList.Items) {
196
+ return;
197
+ }
198
+ const existingItems = grid.WordList.Items.WordListItem || grid.WordList.Items.wordlistitem || [];
199
+ const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
200
+ let filteredItems;
201
+ if (typeof match === 'string') {
202
+ filteredItems = itemsArray.filter((item) => {
203
+ const text = item.Text?.p?.s?.r || item.Text || '';
204
+ return text !== match;
205
+ });
206
+ }
207
+ else {
208
+ filteredItems = itemsArray.filter(match);
209
+ }
210
+ grid.WordList.Items.WordListItem = filteredItems;
211
+ }
212
+ }
@@ -6,6 +6,7 @@ import { extractAllButtonsForTranslation, validateTranslationResults, } from '..
6
6
  import { getZipEntriesFromAdapter, resolveGridsetPassword, } from './gridset/password';
7
7
  import { decryptGridsetEntry } from './gridset/crypto';
8
8
  import { formatGrid3XmlComplete } from './gridset/xmlFormatter';
9
+ import { GridsetSaveHandler } from './gridset/saveMutations';
9
10
  import { calculateColumnDefinitions as calcColumnDefs, calculateRowDefinitions as calcRowDefs, } from './gridset/gridCalculations';
10
11
  import { findButtonPosition as findButtonPos } from './gridset/cellHelpers';
11
12
  import { GridsetValidator } from '../validation/gridsetValidator';
@@ -20,6 +21,11 @@ import { decodeText } from '../utils/io';
20
21
  class GridsetProcessor extends BaseProcessor {
21
22
  constructor(options) {
22
23
  super(options);
24
+ this.capabilities = {
25
+ wordList: 'native',
26
+ preservesAssetsOnSave: true,
27
+ newCellCreation: 'restricted',
28
+ };
23
29
  }
24
30
  // Determine password to use when opening encrypted gridset archives (.gridsetx)
25
31
  getGridsetPassword(source) {
@@ -1565,8 +1571,8 @@ class GridsetProcessor extends BaseProcessor {
1565
1571
  ...(imageData ? { imageData, image_id: resolvedImageEntry } : {}),
1566
1572
  },
1567
1573
  });
1568
- // Add button to page
1569
- page.addButton(button);
1574
+ // Add button to page (load path: do not record as a user mutation)
1575
+ page._loadButton(button);
1570
1576
  // Place button in grid layout (handle colspan/rowspan)
1571
1577
  for (let r = cellY; r < cellY + rowSpan && r < maxRows; r++) {
1572
1578
  for (let c = cellX; c < cellX + colSpan && c < maxCols; c++) {
@@ -2224,6 +2230,35 @@ class GridsetProcessor extends BaseProcessor {
2224
2230
  const AdmZip = (await import('adm-zip')).default;
2225
2231
  const originalZip = new AdmZip(originalPath);
2226
2232
  const outputZip = new AdmZip();
2233
+ // Check if any page has pending mutations
2234
+ const hasPendingMutations = Object.values(tree.pages).some((page) => page.pendingMutations.length > 0);
2235
+ if (hasPendingMutations) {
2236
+ // NEW: Use mutation-based save path
2237
+ const parser = new XMLParser({
2238
+ ignoreAttributes: false,
2239
+ attributeNamePrefix: '@_',
2240
+ });
2241
+ const gridBuilder = new XMLBuilder({
2242
+ ignoreAttributes: false,
2243
+ format: true,
2244
+ indentBy: ' ',
2245
+ suppressEmptyNode: true,
2246
+ suppressBooleanAttributes: false,
2247
+ });
2248
+ GridsetSaveHandler.saveWithMutations(tree, originalZip, outputZip, parser, gridBuilder, (page) => this.createBasicGridXml(page));
2249
+ // Copy remaining files
2250
+ for (const entry of originalZip.getEntries()) {
2251
+ if (entry.isDirectory)
2252
+ continue;
2253
+ if (!outputZip.getEntry(entry.entryName)) {
2254
+ outputZip.addFile(entry.entryName, entry.getData());
2255
+ }
2256
+ }
2257
+ const outputBuffer = outputZip.toBuffer();
2258
+ await writeBinaryToPath(outputPath, outputBuffer);
2259
+ return;
2260
+ }
2261
+ // LEGACY: Original position-based logic continues below...
2227
2262
  // Create a map of pages by name for easy lookup
2228
2263
  const pagesByName = new Map();
2229
2264
  for (const page of Object.values(tree.pages)) {
@@ -2417,43 +2452,6 @@ class GridsetProcessor extends BaseProcessor {
2417
2452
  originalGrid.Grid.WordList.Items.WordListItem = allItems;
2418
2453
  }
2419
2454
  }
2420
- // Process WordList items attached to the page (from personalisation)
2421
- // These are tracked separately and shouldn't create new cells
2422
- // Use a known symbol key to check for WordList items
2423
- const WORDLIST_ITEMS_KEY = 'wordListItems';
2424
- const wordListItems = page[WORDLIST_ITEMS_KEY];
2425
- if (wordListItems && wordListItems.length > 0) {
2426
- // Ensure WordList structure exists
2427
- if (!originalGrid.Grid) {
2428
- originalGrid.Grid = {};
2429
- }
2430
- if (!originalGrid.Grid.WordList) {
2431
- originalGrid.Grid.WordList = {};
2432
- }
2433
- if (!originalGrid.Grid.WordList.Items) {
2434
- originalGrid.Grid.WordList.Items = {};
2435
- }
2436
- const existingItems = originalGrid.Grid.WordList.Items.WordListItem ||
2437
- originalGrid.Grid.WordList.Items.wordlistitem ||
2438
- [];
2439
- const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
2440
- // Add new WordList items with proper Grid 3 format
2441
- for (const item of wordListItems) {
2442
- itemsArray.push({
2443
- Text: {
2444
- p: {
2445
- s: {
2446
- r: item.label,
2447
- },
2448
- },
2449
- },
2450
- Image: '',
2451
- PartOfSpeech: 'Unknown',
2452
- });
2453
- }
2454
- // Update the WordList
2455
- originalGrid.Grid.WordList.Items.WordListItem = itemsArray;
2456
- }
2457
2455
  // Build the updated grid XML and format for Grid 3 compatibility
2458
2456
  let builtXml = gridBuilder.build(originalGrid);
2459
2457
  builtXml = formatGrid3XmlComplete(builtXml);
@@ -18,6 +18,11 @@ function mapObfVisibility(hidden) {
18
18
  class ObfProcessor extends BaseProcessor {
19
19
  constructor(options) {
20
20
  super(options);
21
+ this.capabilities = {
22
+ wordList: 'none',
23
+ preservesAssetsOnSave: true,
24
+ newCellCreation: 'allowed',
25
+ };
21
26
  this.imageCache = new Map(); // Cache for data URLs
22
27
  }
23
28
  /**
@@ -531,6 +536,46 @@ class ObfProcessor extends BaseProcessor {
531
536
  }
532
537
  return { rows: totalRows, columns: totalColumns, order, buttonPositions };
533
538
  }
539
+ /**
540
+ * Apply mutations to a buttons array for OBF export
541
+ * Returns a modified copy of the buttons array with mutations applied
542
+ *
543
+ * Note: addButton mutations are NOT applied because the button is already
544
+ * in the buttons array (added by page.addButton()). We only apply
545
+ * removeButton and updateButton mutations.
546
+ */
547
+ applyMutationsToButtons(buttons, mutations) {
548
+ let modifiedButtons = [...buttons];
549
+ for (const mutation of mutations) {
550
+ switch (mutation.type) {
551
+ case 'addButton':
552
+ // Skip - button is already in the array from page.addButton()
553
+ break;
554
+ case 'removeButton': {
555
+ modifiedButtons = modifiedButtons.filter((b) => b.id !== mutation.buttonId);
556
+ break;
557
+ }
558
+ case 'updateButton': {
559
+ modifiedButtons = modifiedButtons.map((b) => {
560
+ if (b.id === mutation.buttonId) {
561
+ // Create a new AACButton instance with the patched properties
562
+ const patched = Object.create(Object.getPrototypeOf(b));
563
+ Object.assign(patched, b, mutation.patch);
564
+ return patched;
565
+ }
566
+ return b;
567
+ });
568
+ break;
569
+ }
570
+ case 'addWordListItem':
571
+ case 'removeWordListItem':
572
+ case 'clearWordList':
573
+ // OBF doesn't have WordList - skip
574
+ break;
575
+ }
576
+ }
577
+ return modifiedButtons;
578
+ }
534
579
  createObfBoardFromPage(page, fallbackName, metadata, embedData = false) {
535
580
  const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page);
536
581
  const boardName = metadata?.name && page.id === metadata?.defaultHomePageId
@@ -543,6 +588,10 @@ class ObfProcessor extends BaseProcessor {
543
588
  return image;
544
589
  });
545
590
  }
591
+ // Apply mutations if present
592
+ const buttons = page.pendingMutations.length > 0
593
+ ? this.applyMutationsToButtons(page.buttons, page.pendingMutations)
594
+ : page.buttons;
546
595
  return {
547
596
  format: OBF_FORMAT_VERSION,
548
597
  id: page.id,
@@ -557,7 +606,7 @@ class ObfProcessor extends BaseProcessor {
557
606
  columns,
558
607
  order,
559
608
  },
560
- buttons: page.buttons.map((button) => {
609
+ buttons: buttons.map((button) => {
561
610
  const extraButtonInfo = button;
562
611
  const imageId = button.parameters?.image_id ||
563
612
  button.parameters?.imageId ||
@@ -702,6 +751,7 @@ class ObfProcessor extends BaseProcessor {
702
751
  for (const page of Object.values(tree.pages)) {
703
752
  const obfFilename = this.getPageFilename(page.id, tree.metadata);
704
753
  modifiedObfFiles.add(obfFilename);
754
+ // createObfBoardFromPage will automatically apply mutations if present
705
755
  const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
706
756
  const obfContent = JSON.stringify(obfBoard, null, 2);
707
757
  newObfFiles.set(obfFilename, obfContent);
@@ -6,6 +6,11 @@ import { getBasename, encodeText } from '../utils/io';
6
6
  class OpmlProcessor extends BaseProcessor {
7
7
  constructor(options) {
8
8
  super(options);
9
+ this.capabilities = {
10
+ wordList: 'none',
11
+ preservesAssetsOnSave: false,
12
+ newCellCreation: 'allowed',
13
+ };
9
14
  }
10
15
  processOutline(outline, parentId = null) {
11
16
  if (!outline || typeof outline !== 'object') {
@@ -37,7 +42,8 @@ class OpmlProcessor extends BaseProcessor {
37
42
  message: '',
38
43
  targetPageId: childText.replace(/[^a-zA-Z0-9]/g, '_'),
39
44
  });
40
- page.addButton(button);
45
+ // Load path: do not record as a user mutation
46
+ page._loadButton(button);
41
47
  const { page: childPage, childPages: grandChildren } = this.processOutline(child, page.id);
42
48
  if (childPage && childPage.id)
43
49
  childPages.push(childPage, ...grandChildren);
@@ -37,6 +37,11 @@ function mapSnapVisibility(visible) {
37
37
  class SnapProcessor extends BaseProcessor {
38
38
  constructor(symbolResolver = null, options) {
39
39
  super(options);
40
+ this.capabilities = {
41
+ wordList: 'none',
42
+ preservesAssetsOnSave: false,
43
+ newCellCreation: 'allowed',
44
+ };
40
45
  this.symbolResolver = null;
41
46
  this.loadAudio = false;
42
47
  this.pageLayoutPreference = 'scanning'; // Default to scanning for metrics
@@ -574,7 +579,8 @@ class SnapProcessor extends BaseProcessor {
574
579
  // Add to the intended parent page
575
580
  const parentPage = tree.getPage(parentUniqueId);
576
581
  if (parentPage) {
577
- parentPage.addButton(button);
582
+ // Load path: do not record as a user mutation
583
+ parentPage._loadButton(button);
578
584
  // Add button to grid layout if position data is available
579
585
  const gridPositionStr = String(btnRow.GridPosition || '');
580
586
  if (gridPositionStr && gridPositionStr.includes(',')) {
@@ -31,6 +31,11 @@ function mapTouchChatVisibility(visible) {
31
31
  class TouchChatProcessor extends BaseProcessor {
32
32
  constructor(options) {
33
33
  super(options);
34
+ this.capabilities = {
35
+ wordList: 'none',
36
+ preservesAssetsOnSave: false,
37
+ newCellCreation: 'allowed',
38
+ };
34
39
  this.tree = null;
35
40
  this.sourceFile = null;
36
41
  }
@@ -261,8 +266,8 @@ class TouchChatProcessor extends BaseProcessor {
261
266
  // Set button's x and y coordinates
262
267
  button.x = absoluteX;
263
268
  button.y = absoluteY;
264
- // Add button to page
265
- page.addButton(button);
269
+ // Add button to page (load path: do not record as a user mutation)
270
+ page._loadButton(button);
266
271
  // Place button in grid (handle span)
267
272
  for (let r = absoluteY; r < absoluteY + safeSpanY && r < 10; r++) {
268
273
  for (let c = absoluteX; c < absoluteX + safeSpanX && c < 10; c++) {
@@ -363,7 +368,7 @@ class TouchChatProcessor extends BaseProcessor {
363
368
  // Find the page that references this resource
364
369
  const page = Object.values(tree.pages).find((p) => p.id === (numericToRid.get(btnRow.id) || String(btnRow.id)));
365
370
  if (page)
366
- page.addButton(button);
371
+ page._loadButton(button); // load path: do not record as a user mutation
367
372
  });
368
373
  }
369
374
  catch (_e) {
@@ -44,6 +44,7 @@ import { StringCasing } from './stringCasing';
44
44
  import { ValidationResult } from '../validation/validationTypes';
45
45
  import { BinaryOutput, FileAdapter, ProcessorInput } from '../utils/io';
46
46
  import { ZipAdapter } from '../utils/zip';
47
+ import type { ProcessorCapabilities } from '../types/aac';
47
48
  export interface ProcessorConfig {
48
49
  excludeNavigationButtons?: boolean;
49
50
  excludeSystemButtons?: boolean;
@@ -90,6 +91,7 @@ export interface SourceString {
90
91
  }
91
92
  declare abstract class BaseProcessor {
92
93
  protected options: ProcessorConfig;
94
+ abstract readonly capabilities: ProcessorCapabilities;
93
95
  constructor(options?: ProcessorOptions);
94
96
  abstract extractTexts(filePathOrBuffer: ProcessorInput): Promise<string[]>;
95
97
  abstract loadIntoTree(filePathOrBuffer: ProcessorInput): Promise<AACTree>;