@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
@@ -1,5 +1,5 @@
1
- import type { AACStyle, AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata, CellScanningOrder, ScanningSelectionMethod } from '../types/aac';
2
- export type { AACStyle, AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata, CellScanningOrder, ScanningSelectionMethod, };
1
+ import type { AACStyle, AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata, CellScanningOrder, ScanningSelectionMethod, AACWordListItem, AACPageMutation, ProcessorCapabilities } from '../types/aac';
2
+ export type { AACStyle, AACTreeMetadata, SnapMetadata, GridSetMetadata, AstericsGridMetadata, TouchChatMetadata, CellScanningOrder, ScanningSelectionMethod, AACWordListItem, AACPageMutation, ProcessorCapabilities, };
3
3
  export declare enum AACSemanticCategory {
4
4
  COMMUNICATION = "communication",// Speech, text output
5
5
  NAVIGATION = "navigation",// Page/grid navigation
@@ -227,6 +227,7 @@ export declare class AACPage {
227
227
  scanningConfig?: import('../types/aac').ScanningConfig;
228
228
  scanType?: AACScanType;
229
229
  scanBlocksConfig?: AACScanBlock[];
230
+ private _pendingMutations;
230
231
  constructor({ id, name, grid, buttons, parentId, style, locale, descriptionHtml, images, sounds, semantic_ids, clone_ids, scanningConfig, scanBlocksConfig, scanType, }: {
231
232
  id: string;
232
233
  name?: string;
@@ -248,6 +249,46 @@ export declare class AACPage {
248
249
  scanType?: AACScanType;
249
250
  });
250
251
  addButton(button: AACButton): void;
252
+ /**
253
+ * Internal load-path button push: adds a button to the page WITHOUT recording a mutation.
254
+ * Used by processors during loadIntoTree so the loaded baseline isn't treated as user changes.
255
+ * Not part of the public API — consumers should always use addButton.
256
+ */
257
+ _loadButton(button: AACButton): void;
258
+ /**
259
+ * Discard all recorded mutations on this page.
260
+ * Useful as an escape hatch after loadIntoTree if the consumer wants a clean baseline.
261
+ */
262
+ clearMutations(): void;
263
+ /**
264
+ * Get the list of pending mutations for this page (read-only)
265
+ */
266
+ get pendingMutations(): readonly AACPageMutation[];
267
+ /**
268
+ * Remove a button by ID
269
+ * @param buttonId - The ID of the button to remove
270
+ */
271
+ removeButton(buttonId: string): void;
272
+ /**
273
+ * Update a button by merging a patch
274
+ * @param buttonId - The ID of the button to update
275
+ * @param patch - Partial button object with fields to update
276
+ */
277
+ updateButton(buttonId: string, patch: Partial<AACButton>): void;
278
+ /**
279
+ * Add an item to the page's WordList (for formats with dynamic content cells)
280
+ * @param item - WordList item to add
281
+ */
282
+ addWordListItem(item: AACWordListItem): void;
283
+ /**
284
+ * Remove items from the page's WordList
285
+ * @param textOrPredicate - Text to match or predicate function to filter items
286
+ */
287
+ removeWordListItem(textOrPredicate: string | ((item: AACWordListItem) => boolean)): void;
288
+ /**
289
+ * Clear all items from the page's WordList
290
+ */
291
+ clearWordList(): void;
251
292
  }
252
293
  export declare class AACTree {
253
294
  pages: {
@@ -172,6 +172,8 @@ class AACButton {
172
172
  exports.AACButton = AACButton;
173
173
  class AACPage {
174
174
  constructor({ id, name = '', grid = [], buttons = [], parentId = null, style, locale, descriptionHtml, images, sounds, semantic_ids, clone_ids, scanningConfig, scanBlocksConfig, scanType, }) {
175
+ // Mutation tracking
176
+ this._pendingMutations = [];
175
177
  this.id = id;
176
178
  this.name = name;
177
179
  if (Array.isArray(grid)) {
@@ -200,6 +202,64 @@ class AACPage {
200
202
  }
201
203
  addButton(button) {
202
204
  this.buttons.push(button);
205
+ // Record the mutation
206
+ this._pendingMutations.push({ type: 'addButton', button });
207
+ }
208
+ /**
209
+ * Internal load-path button push: adds a button to the page WITHOUT recording a mutation.
210
+ * Used by processors during loadIntoTree so the loaded baseline isn't treated as user changes.
211
+ * Not part of the public API — consumers should always use addButton.
212
+ */
213
+ _loadButton(button) {
214
+ this.buttons.push(button);
215
+ }
216
+ /**
217
+ * Discard all recorded mutations on this page.
218
+ * Useful as an escape hatch after loadIntoTree if the consumer wants a clean baseline.
219
+ */
220
+ clearMutations() {
221
+ this._pendingMutations = [];
222
+ }
223
+ /**
224
+ * Get the list of pending mutations for this page (read-only)
225
+ */
226
+ get pendingMutations() {
227
+ return Object.freeze([...this._pendingMutations]);
228
+ }
229
+ /**
230
+ * Remove a button by ID
231
+ * @param buttonId - The ID of the button to remove
232
+ */
233
+ removeButton(buttonId) {
234
+ this._pendingMutations.push({ type: 'removeButton', buttonId });
235
+ }
236
+ /**
237
+ * Update a button by merging a patch
238
+ * @param buttonId - The ID of the button to update
239
+ * @param patch - Partial button object with fields to update
240
+ */
241
+ updateButton(buttonId, patch) {
242
+ this._pendingMutations.push({ type: 'updateButton', buttonId, patch });
243
+ }
244
+ /**
245
+ * Add an item to the page's WordList (for formats with dynamic content cells)
246
+ * @param item - WordList item to add
247
+ */
248
+ addWordListItem(item) {
249
+ this._pendingMutations.push({ type: 'addWordListItem', item });
250
+ }
251
+ /**
252
+ * Remove items from the page's WordList
253
+ * @param textOrPredicate - Text to match or predicate function to filter items
254
+ */
255
+ removeWordListItem(textOrPredicate) {
256
+ this._pendingMutations.push({ type: 'removeWordListItem', match: textOrPredicate });
257
+ }
258
+ /**
259
+ * Clear all items from the page's WordList
260
+ */
261
+ clearWordList() {
262
+ this._pendingMutations.push({ type: 'clearWordList' });
203
263
  }
204
264
  }
205
265
  exports.AACPage = AACPage;
@@ -2,6 +2,11 @@ import { BaseProcessor, ProcessorOptions, ExtractStringsResult, TranslatedString
2
2
  import { AACTree } from '../core/treeStructure';
3
3
  import { ProcessorInput } from '../utils/io';
4
4
  declare class ApplePanelsProcessor extends BaseProcessor {
5
+ readonly capabilities: {
6
+ wordList: "none";
7
+ preservesAssetsOnSave: boolean;
8
+ newCellCreation: "allowed";
9
+ };
5
10
  constructor(options?: ProcessorOptions);
6
11
  private parseRect;
7
12
  private pixelToGrid;
@@ -51,6 +51,11 @@ function normalizeActionParameters(input) {
51
51
  class ApplePanelsProcessor extends baseProcessor_1.BaseProcessor {
52
52
  constructor(options) {
53
53
  super(options);
54
+ this.capabilities = {
55
+ wordList: 'none',
56
+ preservesAssetsOnSave: false,
57
+ newCellCreation: 'allowed',
58
+ };
54
59
  }
55
60
  // Helper function to parse Apple Panels Rect format "{{x, y}, {width, height}}"
56
61
  parseRect(rectString) {
@@ -220,7 +225,8 @@ class ApplePanelsProcessor extends baseProcessor_1.BaseProcessor {
220
225
  fontWeight: btn.DisplayImageWeight === 'bold' ? 'bold' : 'normal',
221
226
  },
222
227
  });
223
- page.addButton(button);
228
+ // Load path: do not record as a user mutation
229
+ page._loadButton(button);
224
230
  if (btn.Rect) {
225
231
  const rect = this.parseRect(btn.Rect);
226
232
  if (rect) {
@@ -17,6 +17,11 @@ export declare function calculateLuminance(hexColor: string): number;
17
17
  */
18
18
  export declare function getContrastingTextColor(backgroundColor: string): string;
19
19
  declare class AstericsGridProcessor extends BaseProcessor {
20
+ readonly capabilities: {
21
+ wordList: "none";
22
+ preservesAssetsOnSave: boolean;
23
+ newCellCreation: "allowed";
24
+ };
20
25
  private loadAudio;
21
26
  constructor(options?: ProcessorOptions & {
22
27
  loadAudio?: boolean;
@@ -552,6 +552,11 @@ function mapAstericsVisibility(hidden) {
552
552
  class AstericsGridProcessor extends baseProcessor_1.BaseProcessor {
553
553
  constructor(options = {}) {
554
554
  super(options);
555
+ this.capabilities = {
556
+ wordList: 'none',
557
+ preservesAssetsOnSave: false,
558
+ newCellCreation: 'allowed',
559
+ };
555
560
  this.loadAudio = false;
556
561
  this.loadAudio = options.loadAudio || false;
557
562
  }
@@ -717,7 +722,8 @@ class AstericsGridProcessor extends baseProcessor_1.BaseProcessor {
717
722
  }
718
723
  grid.gridElements.forEach((element) => {
719
724
  const button = this.createButtonFromElement(element, colorConfig, activeColorSchemeDefinition);
720
- page.addButton(button);
725
+ // Load path: do not record as a user mutation
726
+ page._loadButton(button);
721
727
  const buttonX = element.x || 0;
722
728
  const buttonY = element.y || 0;
723
729
  const buttonWidth = element.width || 1;
@@ -2,6 +2,11 @@ import { BaseProcessor, ProcessorOptions, ExtractStringsResult, TranslatedString
2
2
  import { AACTree } from '../core/treeStructure';
3
3
  import { ProcessorInput } from '../utils/io';
4
4
  declare class DotProcessor extends BaseProcessor {
5
+ readonly capabilities: {
6
+ wordList: "none";
7
+ preservesAssetsOnSave: boolean;
8
+ newCellCreation: "allowed";
9
+ };
5
10
  constructor(options?: ProcessorOptions);
6
11
  private parseDotFile;
7
12
  extractTexts(filePathOrBuffer: ProcessorInput): Promise<string[]>;
@@ -8,6 +8,11 @@ const io_1 = require("../utils/io");
8
8
  class DotProcessor extends baseProcessor_1.BaseProcessor {
9
9
  constructor(options) {
10
10
  super(options);
11
+ this.capabilities = {
12
+ wordList: 'none',
13
+ preservesAssetsOnSave: false,
14
+ newCellCreation: 'allowed',
15
+ };
11
16
  }
12
17
  parseDotFile(content) {
13
18
  const nodes = new Map();
@@ -115,7 +120,8 @@ class DotProcessor extends baseProcessor_1.BaseProcessor {
115
120
  });
116
121
  tree.addPage(page);
117
122
  // Add a self button so single-node graphs yield one button
118
- page.addButton(new treeStructure_1.AACButton({
123
+ // Load path: do not record as a user mutation
124
+ page._loadButton(new treeStructure_1.AACButton({
119
125
  id: `${node.id}_self`,
120
126
  label: node.label,
121
127
  message: node.label,
@@ -136,7 +142,8 @@ class DotProcessor extends baseProcessor_1.BaseProcessor {
136
142
  message: '',
137
143
  targetPageId: edge.to,
138
144
  });
139
- fromPage.addButton(button);
145
+ // Load path: do not record as a user mutation
146
+ fromPage._loadButton(button);
140
147
  }
141
148
  }
142
149
  return tree;
@@ -7,6 +7,11 @@ import { AACTree } from '../core/treeStructure';
7
7
  * Supports visual styling, navigation links, and vocabulary analysis workflows
8
8
  */
9
9
  export declare class ExcelProcessor extends BaseProcessor {
10
+ readonly capabilities: {
11
+ wordList: "none";
12
+ preservesAssetsOnSave: boolean;
13
+ newCellCreation: "allowed";
14
+ };
10
15
  private static readonly NAVIGATION_BUTTONS;
11
16
  /**
12
17
  * Extract all text content from an Excel file
@@ -33,6 +33,14 @@ const treeStructure_1 = require("../core/treeStructure");
33
33
  * Supports visual styling, navigation links, and vocabulary analysis workflows
34
34
  */
35
35
  class ExcelProcessor extends baseProcessor_1.BaseProcessor {
36
+ constructor() {
37
+ super(...arguments);
38
+ this.capabilities = {
39
+ wordList: 'none',
40
+ preservesAssetsOnSave: false,
41
+ newCellCreation: 'allowed',
42
+ };
43
+ }
36
44
  /**
37
45
  * Extract all text content from an Excel file
38
46
  * @param filePathOrBuffer - Path to Excel file or Buffer containing Excel data
@@ -0,0 +1,39 @@
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 { AACTree, AACPage } from '../../core/treeStructure';
8
+ import type { AACButton } from '../../types/aac';
9
+ export declare class GridsetSaveHandler {
10
+ private AdmZip;
11
+ private XMLParser;
12
+ private XMLBuilder;
13
+ constructor();
14
+ /**
15
+ * Show deprecation warning for legacy save path
16
+ */
17
+ static warnLegacySave(): void;
18
+ /**
19
+ * Save using mutation-based logic
20
+ * Fixes bugs A, B, C by processing explicit mutations
21
+ */
22
+ static saveWithMutations(tree: AACTree, originalZip: any, outputZip: any, parser: any, gridBuilder: any, createBasicGridXml: (page: AACPage) => string): void;
23
+ /**
24
+ * Apply button changes to a cell
25
+ */
26
+ static applyButtonToCell(cell: any, button: AACButton, patch?: Partial<AACButton>): void;
27
+ /**
28
+ * Add an item to the WordList with de-duplication (Bug A fix)
29
+ */
30
+ static addWordListItemToGrid(grid: any, item: {
31
+ text: string;
32
+ image?: string;
33
+ partOfSpeech?: string;
34
+ }): void;
35
+ /**
36
+ * Remove items from the WordList
37
+ */
38
+ static removeWordListItemFromGrid(grid: any, match: string | ((item: any) => boolean)): void;
39
+ }
@@ -0,0 +1,216 @@
1
+ "use strict";
2
+ /**
3
+ * Gridset Save Mutations Module
4
+ *
5
+ * Handles saving AACTree mutations back to Gridset files.
6
+ * This module extracts the save logic from gridsetProcessor for better modularity.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.GridsetSaveHandler = void 0;
10
+ const xmlFormatter_1 = require("./xmlFormatter");
11
+ class GridsetSaveHandler {
12
+ constructor() {
13
+ // Dynamic imports for browser compatibility
14
+ }
15
+ /**
16
+ * Show deprecation warning for legacy save path
17
+ */
18
+ static warnLegacySave() {
19
+ const key = 'gridset_legacy_save_warned';
20
+ if (!global[key]) {
21
+ console.warn('saveModifiedTree: detected button changes without recorded mutations. ' +
22
+ 'This will continue to work in 0.x but is deprecated. ' +
23
+ 'Use page.addButton / page.addWordListItem to make changes explicit.');
24
+ global[key] = true;
25
+ }
26
+ }
27
+ /**
28
+ * Save using mutation-based logic
29
+ * Fixes bugs A, B, C by processing explicit mutations
30
+ */
31
+ static saveWithMutations(tree, originalZip, outputZip, parser, gridBuilder, createBasicGridXml) {
32
+ for (const page of Object.values(tree.pages)) {
33
+ // Skip pages with no mutations
34
+ if (page.pendingMutations.length === 0) {
35
+ continue;
36
+ }
37
+ const gridPath = `Grids/${page.name}/grid.xml`;
38
+ // Load or create grid.xml
39
+ const originalEntry = originalZip.getEntry(gridPath);
40
+ let originalGrid;
41
+ if (originalEntry) {
42
+ const originalContent = originalEntry.getData().toString('utf-8');
43
+ originalGrid = parser.parse(originalContent);
44
+ if (!originalGrid.Grid) {
45
+ originalGrid = null;
46
+ }
47
+ }
48
+ if (!originalGrid || !originalGrid.Grid) {
49
+ const basicGrid = createBasicGridXml(page);
50
+ const buffer = Buffer.from(basicGrid, 'utf8');
51
+ outputZip.addFile(gridPath, buffer);
52
+ continue;
53
+ }
54
+ // Index original cells by position
55
+ const cellsByPosition = new Map();
56
+ const cellArray = Array.isArray(originalGrid.Grid.Cells?.Cell)
57
+ ? originalGrid.Grid.Cells.Cell
58
+ : originalGrid.Grid.Cells?.Cell
59
+ ? [originalGrid.Grid.Cells.Cell]
60
+ : [];
61
+ for (const cell of cellArray) {
62
+ const x = cell['@_X'] !== undefined ? parseInt(String(cell['@_X']), 10) : undefined;
63
+ const y = parseInt(String(cell['@_Y'] || cell['@_Row'] || '0'), 10);
64
+ if (x !== undefined) {
65
+ cellsByPosition.set(`${x},${y}`, cell);
66
+ }
67
+ }
68
+ // Process mutations in order
69
+ for (const mutation of page.pendingMutations) {
70
+ switch (mutation.type) {
71
+ case 'addButton': {
72
+ const button = mutation.button;
73
+ const x = button.x ?? 0;
74
+ const y = button.y ?? 0;
75
+ const cell = cellsByPosition.get(`${x},${y}`);
76
+ if (cell && cell.Content) {
77
+ GridsetSaveHandler.applyButtonToCell(cell, button);
78
+ }
79
+ else {
80
+ // Bug C fix: warn instead of silently dropping
81
+ console.warn(`[Gridset] Cannot add button at (${x},${y}) - cell does not exist. ` +
82
+ `Use addWordListItem for dynamic content.`);
83
+ }
84
+ break;
85
+ }
86
+ case 'removeButton': {
87
+ const button = page.buttons.find((b) => b.id === mutation.buttonId);
88
+ if (button) {
89
+ const x = button.x ?? 0;
90
+ const y = button.y ?? 0;
91
+ const cell = cellsByPosition.get(`${x},${y}`);
92
+ if (cell && cell.Content) {
93
+ cell.Content.Visibility = 'Hidden';
94
+ }
95
+ }
96
+ break;
97
+ }
98
+ case 'updateButton': {
99
+ const button = page.buttons.find((b) => b.id === mutation.buttonId);
100
+ if (button) {
101
+ const x = button.x ?? 0;
102
+ const y = button.y ?? 0;
103
+ const cell = cellsByPosition.get(`${x},${y}`);
104
+ if (cell && cell.Content) {
105
+ GridsetSaveHandler.applyButtonToCell(cell, button, mutation.patch);
106
+ }
107
+ }
108
+ break;
109
+ }
110
+ case 'addWordListItem': {
111
+ GridsetSaveHandler.addWordListItemToGrid(originalGrid.Grid, mutation.item);
112
+ break;
113
+ }
114
+ case 'removeWordListItem': {
115
+ GridsetSaveHandler.removeWordListItemFromGrid(originalGrid.Grid, mutation.match);
116
+ break;
117
+ }
118
+ case 'clearWordList': {
119
+ if (originalGrid.Grid.WordList && originalGrid.Grid.WordList.Items) {
120
+ originalGrid.Grid.WordList.Items.WordListItem = [];
121
+ }
122
+ break;
123
+ }
124
+ }
125
+ }
126
+ // Build and write the updated grid XML
127
+ let builtXml = gridBuilder.build(originalGrid);
128
+ builtXml = (0, xmlFormatter_1.formatGrid3XmlComplete)(builtXml);
129
+ outputZip.addFile(gridPath, Buffer.from(builtXml, 'utf8'));
130
+ }
131
+ }
132
+ /**
133
+ * Apply button changes to a cell
134
+ */
135
+ static applyButtonToCell(cell, button, patch) {
136
+ const updates = patch ? { ...button, ...patch } : button;
137
+ const isPlaceholderLabel = !updates.label ||
138
+ updates.label.startsWith('Cell_') ||
139
+ updates.label.startsWith('AutoContent_') ||
140
+ updates.label.startsWith('Prediction ');
141
+ if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) {
142
+ const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage;
143
+ if (!isPlaceholderLabel && updates.label) {
144
+ captionAndImage.Caption = updates.label;
145
+ if (captionAndImage['@_xsi:nil'] || captionAndImage['xsi:nil']) {
146
+ delete captionAndImage['@_xsi:nil'];
147
+ delete captionAndImage['xsi:nil'];
148
+ }
149
+ }
150
+ if (updates.image) {
151
+ captionAndImage.Image = updates.image;
152
+ }
153
+ }
154
+ const isPlaceholderMessage = !updates.message ||
155
+ updates.message.startsWith('Cell_') ||
156
+ updates.message.startsWith('AutoContent_') ||
157
+ updates.message.startsWith('Prediction ');
158
+ if (!isPlaceholderMessage &&
159
+ updates.message &&
160
+ updates.message !== updates.label &&
161
+ !cell.Content.Commands) {
162
+ cell.Content['#text'] = updates.message;
163
+ }
164
+ }
165
+ /**
166
+ * Add an item to the WordList with de-duplication (Bug A fix)
167
+ */
168
+ static addWordListItemToGrid(grid, item) {
169
+ if (!grid.WordList) {
170
+ grid.WordList = {};
171
+ }
172
+ if (!grid.WordList.Items) {
173
+ grid.WordList.Items = {};
174
+ }
175
+ const existingItems = grid.WordList.Items.WordListItem || grid.WordList.Items.wordlistitem || [];
176
+ const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
177
+ // De-duplicate by text
178
+ const existingTexts = new Set(itemsArray
179
+ .map((item) => {
180
+ if (typeof item.Text === 'string')
181
+ return item.Text;
182
+ return item.Text?.p?.s?.r || '';
183
+ })
184
+ .filter(Boolean));
185
+ if (!existingTexts.has(item.text)) {
186
+ itemsArray.push({
187
+ Text: { p: { s: { r: item.text } } },
188
+ Image: item.image || '',
189
+ PartOfSpeech: item.partOfSpeech || 'Unknown',
190
+ });
191
+ grid.WordList.Items.WordListItem = itemsArray;
192
+ }
193
+ }
194
+ /**
195
+ * Remove items from the WordList
196
+ */
197
+ static removeWordListItemFromGrid(grid, match) {
198
+ if (!grid.WordList || !grid.WordList.Items) {
199
+ return;
200
+ }
201
+ const existingItems = grid.WordList.Items.WordListItem || grid.WordList.Items.wordlistitem || [];
202
+ const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
203
+ let filteredItems;
204
+ if (typeof match === 'string') {
205
+ filteredItems = itemsArray.filter((item) => {
206
+ const text = item.Text?.p?.s?.r || item.Text || '';
207
+ return text !== match;
208
+ });
209
+ }
210
+ else {
211
+ filteredItems = itemsArray.filter(match);
212
+ }
213
+ grid.WordList.Items.WordListItem = filteredItems;
214
+ }
215
+ }
216
+ exports.GridsetSaveHandler = GridsetSaveHandler;
@@ -4,6 +4,11 @@ import { type ButtonForTranslation, type LLMLTranslationResult } from '../utilit
4
4
  import { ValidationResult } from '../validation/validationTypes';
5
5
  import { ProcessorInput } from '../utils/io';
6
6
  declare class GridsetProcessor extends BaseProcessor {
7
+ readonly capabilities: {
8
+ wordList: "native";
9
+ preservesAssetsOnSave: boolean;
10
+ newCellCreation: "restricted";
11
+ };
7
12
  constructor(options?: ProcessorOptions);
8
13
  private getGridsetPassword;
9
14
  private ensureAlphaChannel;
@@ -32,6 +32,7 @@ const translationProcessor_1 = require("../utilities/translation/translationProc
32
32
  const password_1 = require("./gridset/password");
33
33
  const crypto_1 = require("./gridset/crypto");
34
34
  const xmlFormatter_1 = require("./gridset/xmlFormatter");
35
+ const saveMutations_1 = require("./gridset/saveMutations");
35
36
  const gridCalculations_1 = require("./gridset/gridCalculations");
36
37
  const cellHelpers_1 = require("./gridset/cellHelpers");
37
38
  const gridsetValidator_1 = require("../validation/gridsetValidator");
@@ -46,6 +47,11 @@ const io_1 = require("../utils/io");
46
47
  class GridsetProcessor extends baseProcessor_1.BaseProcessor {
47
48
  constructor(options) {
48
49
  super(options);
50
+ this.capabilities = {
51
+ wordList: 'native',
52
+ preservesAssetsOnSave: true,
53
+ newCellCreation: 'restricted',
54
+ };
49
55
  }
50
56
  // Determine password to use when opening encrypted gridset archives (.gridsetx)
51
57
  getGridsetPassword(source) {
@@ -1591,8 +1597,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1591
1597
  ...(imageData ? { imageData, image_id: resolvedImageEntry } : {}),
1592
1598
  },
1593
1599
  });
1594
- // Add button to page
1595
- page.addButton(button);
1600
+ // Add button to page (load path: do not record as a user mutation)
1601
+ page._loadButton(button);
1596
1602
  // Place button in grid layout (handle colspan/rowspan)
1597
1603
  for (let r = cellY; r < cellY + rowSpan && r < maxRows; r++) {
1598
1604
  for (let c = cellX; c < cellX + colSpan && c < maxCols; c++) {
@@ -2250,6 +2256,35 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2250
2256
  const AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
2251
2257
  const originalZip = new AdmZip(originalPath);
2252
2258
  const outputZip = new AdmZip();
2259
+ // Check if any page has pending mutations
2260
+ const hasPendingMutations = Object.values(tree.pages).some((page) => page.pendingMutations.length > 0);
2261
+ if (hasPendingMutations) {
2262
+ // NEW: Use mutation-based save path
2263
+ const parser = new fast_xml_parser_1.XMLParser({
2264
+ ignoreAttributes: false,
2265
+ attributeNamePrefix: '@_',
2266
+ });
2267
+ const gridBuilder = new fast_xml_parser_1.XMLBuilder({
2268
+ ignoreAttributes: false,
2269
+ format: true,
2270
+ indentBy: ' ',
2271
+ suppressEmptyNode: true,
2272
+ suppressBooleanAttributes: false,
2273
+ });
2274
+ saveMutations_1.GridsetSaveHandler.saveWithMutations(tree, originalZip, outputZip, parser, gridBuilder, (page) => this.createBasicGridXml(page));
2275
+ // Copy remaining files
2276
+ for (const entry of originalZip.getEntries()) {
2277
+ if (entry.isDirectory)
2278
+ continue;
2279
+ if (!outputZip.getEntry(entry.entryName)) {
2280
+ outputZip.addFile(entry.entryName, entry.getData());
2281
+ }
2282
+ }
2283
+ const outputBuffer = outputZip.toBuffer();
2284
+ await writeBinaryToPath(outputPath, outputBuffer);
2285
+ return;
2286
+ }
2287
+ // LEGACY: Original position-based logic continues below...
2253
2288
  // Create a map of pages by name for easy lookup
2254
2289
  const pagesByName = new Map();
2255
2290
  for (const page of Object.values(tree.pages)) {
@@ -2443,43 +2478,6 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2443
2478
  originalGrid.Grid.WordList.Items.WordListItem = allItems;
2444
2479
  }
2445
2480
  }
2446
- // Process WordList items attached to the page (from personalisation)
2447
- // These are tracked separately and shouldn't create new cells
2448
- // Use a known symbol key to check for WordList items
2449
- const WORDLIST_ITEMS_KEY = 'wordListItems';
2450
- const wordListItems = page[WORDLIST_ITEMS_KEY];
2451
- if (wordListItems && wordListItems.length > 0) {
2452
- // Ensure WordList structure exists
2453
- if (!originalGrid.Grid) {
2454
- originalGrid.Grid = {};
2455
- }
2456
- if (!originalGrid.Grid.WordList) {
2457
- originalGrid.Grid.WordList = {};
2458
- }
2459
- if (!originalGrid.Grid.WordList.Items) {
2460
- originalGrid.Grid.WordList.Items = {};
2461
- }
2462
- const existingItems = originalGrid.Grid.WordList.Items.WordListItem ||
2463
- originalGrid.Grid.WordList.Items.wordlistitem ||
2464
- [];
2465
- const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
2466
- // Add new WordList items with proper Grid 3 format
2467
- for (const item of wordListItems) {
2468
- itemsArray.push({
2469
- Text: {
2470
- p: {
2471
- s: {
2472
- r: item.label,
2473
- },
2474
- },
2475
- },
2476
- Image: '',
2477
- PartOfSpeech: 'Unknown',
2478
- });
2479
- }
2480
- // Update the WordList
2481
- originalGrid.Grid.WordList.Items.WordListItem = itemsArray;
2482
- }
2483
2481
  // Build the updated grid XML and format for Grid 3 compatibility
2484
2482
  let builtXml = gridBuilder.build(originalGrid);
2485
2483
  builtXml = (0, xmlFormatter_1.formatGrid3XmlComplete)(builtXml);