Skip to content

Commit f03c187

Browse files
committed
gridset updates
1 parent 3c1352b commit f03c187

2 files changed

Lines changed: 311 additions & 0 deletions

File tree

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
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+
8+
import { AACTree, AACPage } from '../../core/treeStructure';
9+
import type { AACButton } from '../../types/aac';
10+
import { formatGrid3XmlComplete } from './xmlFormatter';
11+
12+
export class GridsetSaveHandler {
13+
private AdmZip: any;
14+
private XMLParser: any;
15+
private XMLBuilder: any;
16+
17+
constructor() {
18+
// Dynamic imports for browser compatibility
19+
}
20+
21+
/**
22+
* Show deprecation warning for legacy save path
23+
*/
24+
static warnLegacySave(): void {
25+
const key = 'gridset_legacy_save_warned';
26+
if (!(global as any)[key]) {
27+
console.warn(
28+
'saveModifiedTree: detected button changes without recorded mutations. ' +
29+
'This will continue to work in 0.x but is deprecated. ' +
30+
'Use page.addButton / page.addWordListItem to make changes explicit.'
31+
);
32+
(global as any)[key] = true;
33+
}
34+
}
35+
36+
/**
37+
* Save using mutation-based logic
38+
* Fixes bugs A, B, C by processing explicit mutations
39+
*/
40+
static async saveWithMutations(
41+
tree: AACTree,
42+
originalZip: any,
43+
outputZip: any,
44+
parser: any,
45+
gridBuilder: any,
46+
createBasicGridXml: (page: AACPage) => string
47+
): Promise<void> {
48+
for (const page of Object.values(tree.pages)) {
49+
// Skip pages with no mutations
50+
if (page.pendingMutations.length === 0) {
51+
continue;
52+
}
53+
54+
const gridPath = `Grids/${page.name}/grid.xml`;
55+
56+
// Load or create grid.xml
57+
const originalEntry = originalZip.getEntry(gridPath);
58+
let originalGrid: any;
59+
60+
if (originalEntry) {
61+
const originalContent = originalEntry.getData().toString('utf-8');
62+
originalGrid = parser.parse(originalContent);
63+
if (!originalGrid.Grid) {
64+
originalGrid = null;
65+
}
66+
}
67+
68+
if (!originalGrid || !originalGrid.Grid) {
69+
const basicGrid = createBasicGridXml(page);
70+
const buffer = Buffer.from(basicGrid, 'utf8');
71+
outputZip.addFile(gridPath, buffer);
72+
continue;
73+
}
74+
75+
// Index original cells by position
76+
const cellsByPosition = new Map<string, any>();
77+
const cellArray = Array.isArray(originalGrid.Grid.Cells?.Cell)
78+
? originalGrid.Grid.Cells.Cell
79+
: originalGrid.Grid.Cells?.Cell
80+
? [originalGrid.Grid.Cells.Cell]
81+
: [];
82+
83+
for (const cell of cellArray) {
84+
const x = cell['@_X'] !== undefined ? parseInt(String(cell['@_X']), 10) : undefined;
85+
const y = parseInt(String(cell['@_Y'] || cell['@_Row'] || '0'), 10);
86+
if (x !== undefined) {
87+
cellsByPosition.set(`${x},${y}`, cell);
88+
}
89+
}
90+
91+
// Process mutations in order
92+
for (const mutation of page.pendingMutations) {
93+
switch (mutation.type) {
94+
case 'addButton': {
95+
const button = mutation.button;
96+
const x = button.x ?? 0;
97+
const y = button.y ?? 0;
98+
const cell = cellsByPosition.get(`${x},${y}`);
99+
100+
if (cell && cell.Content) {
101+
GridsetSaveHandler.applyButtonToCell(cell, button);
102+
} else {
103+
// Bug C fix: warn instead of silently dropping
104+
console.warn(
105+
`[Gridset] Cannot add button at (${x},${y}) - cell does not exist. ` +
106+
`Use addWordListItem for dynamic content.`
107+
);
108+
}
109+
break;
110+
}
111+
112+
case 'removeButton': {
113+
const button = page.buttons.find((b) => b.id === mutation.buttonId);
114+
if (button) {
115+
const x = button.x ?? 0;
116+
const y = button.y ?? 0;
117+
const cell = cellsByPosition.get(`${x},${y}`);
118+
if (cell && cell.Content) {
119+
cell.Content.Visibility = 'Hidden';
120+
}
121+
}
122+
break;
123+
}
124+
125+
case 'updateButton': {
126+
const button = page.buttons.find((b) => b.id === mutation.buttonId);
127+
if (button) {
128+
const x = button.x ?? 0;
129+
const y = button.y ?? 0;
130+
const cell = cellsByPosition.get(`${x},${y}`);
131+
if (cell && cell.Content) {
132+
GridsetSaveHandler.applyButtonToCell(cell, button, mutation.patch);
133+
}
134+
}
135+
break;
136+
}
137+
138+
case 'addWordListItem': {
139+
GridsetSaveHandler.addWordListItemToGrid(originalGrid.Grid, mutation.item);
140+
break;
141+
}
142+
143+
case 'removeWordListItem': {
144+
GridsetSaveHandler.removeWordListItemFromGrid(originalGrid.Grid, mutation.match);
145+
break;
146+
}
147+
148+
case 'clearWordList': {
149+
if (originalGrid.Grid.WordList && originalGrid.Grid.WordList.Items) {
150+
originalGrid.Grid.WordList.Items.WordListItem = [];
151+
}
152+
break;
153+
}
154+
}
155+
}
156+
157+
// Build and write the updated grid XML
158+
let builtXml = gridBuilder.build(originalGrid);
159+
builtXml = formatGrid3XmlComplete(builtXml);
160+
outputZip.addFile(gridPath, Buffer.from(builtXml, 'utf8'));
161+
}
162+
}
163+
164+
/**
165+
* Apply button changes to a cell
166+
*/
167+
static applyButtonToCell(cell: any, button: AACButton, patch?: Partial<AACButton>): void {
168+
const updates = patch ? { ...button, ...patch } : button;
169+
170+
const isPlaceholderLabel =
171+
!updates.label ||
172+
updates.label.startsWith('Cell_') ||
173+
updates.label.startsWith('AutoContent_') ||
174+
updates.label.startsWith('Prediction ');
175+
176+
if (cell.Content.CaptionAndImage || cell.Content.captionAndImage) {
177+
const captionAndImage = cell.Content.CaptionAndImage || cell.Content.captionAndImage;
178+
179+
if (!isPlaceholderLabel && updates.label) {
180+
captionAndImage.Caption = updates.label;
181+
if (captionAndImage['@_xsi:nil'] || captionAndImage['xsi:nil']) {
182+
delete captionAndImage['@_xsi:nil'];
183+
delete captionAndImage['xsi:nil'];
184+
}
185+
}
186+
187+
if (updates.image) {
188+
captionAndImage.Image = updates.image;
189+
}
190+
}
191+
192+
const isPlaceholderMessage =
193+
!updates.message ||
194+
updates.message.startsWith('Cell_') ||
195+
updates.message.startsWith('AutoContent_') ||
196+
updates.message.startsWith('Prediction ');
197+
198+
if (
199+
!isPlaceholderMessage &&
200+
updates.message &&
201+
updates.message !== updates.label &&
202+
!cell.Content.Commands
203+
) {
204+
cell.Content['#text'] = updates.message;
205+
}
206+
}
207+
208+
/**
209+
* Add an item to the WordList with de-duplication (Bug A fix)
210+
*/
211+
static addWordListItemToGrid(
212+
grid: any,
213+
item: { text: string; image?: string; partOfSpeech?: string }
214+
): void {
215+
if (!grid.WordList) {
216+
grid.WordList = {};
217+
}
218+
if (!grid.WordList.Items) {
219+
grid.WordList.Items = {};
220+
}
221+
222+
const existingItems =
223+
grid.WordList.Items.WordListItem || grid.WordList.Items.wordlistitem || [];
224+
const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
225+
226+
// De-duplicate by text
227+
const existingTexts = new Set(
228+
itemsArray.map((item: any) => item.Text?.p?.s?.r || item.Text || '').filter(Boolean)
229+
);
230+
231+
if (!existingTexts.has(item.text)) {
232+
itemsArray.push({
233+
Text: { p: { s: { r: item.text } } },
234+
Image: item.image || '',
235+
PartOfSpeech: item.partOfSpeech || 'Unknown',
236+
});
237+
grid.WordList.Items.WordListItem = itemsArray;
238+
}
239+
}
240+
241+
/**
242+
* Remove items from the WordList
243+
*/
244+
static removeWordListItemFromGrid(
245+
grid: any,
246+
match: string | ((item: any) => boolean)
247+
): void {
248+
if (!grid.WordList || !grid.WordList.Items) {
249+
return;
250+
}
251+
252+
const existingItems =
253+
grid.WordList.Items.WordListItem || grid.WordList.Items.wordlistitem || [];
254+
const itemsArray = Array.isArray(existingItems) ? existingItems : [existingItems];
255+
256+
let filteredItems: any[];
257+
if (typeof match === 'string') {
258+
filteredItems = itemsArray.filter((item: any) => {
259+
const text = item.Text?.p?.s?.r || item.Text || '';
260+
return text !== match;
261+
});
262+
} else {
263+
filteredItems = itemsArray.filter(match);
264+
}
265+
266+
grid.WordList.Items.WordListItem = filteredItems;
267+
}
268+
}

src/processors/gridsetProcessor.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
} from './gridset/password';
3131
import { decryptGridsetEntry } from './gridset/crypto';
3232
import { formatGrid3XmlComplete } from './gridset/xmlFormatter';
33+
import { GridsetSaveHandler } from './gridset/saveMutations';
3334
import {
3435
calculateColumnDefinitions as calcColumnDefs,
3536
calculateRowDefinitions as calcRowDefs,
@@ -2527,6 +2528,48 @@ class GridsetProcessor extends BaseProcessor {
25272528
const originalZip = new AdmZip(originalPath);
25282529
const outputZip = new AdmZip();
25292530

2531+
// Check if any page has pending mutations
2532+
const hasPendingMutations = Object.values(tree.pages).some(
2533+
(page) => page.pendingMutations.length > 0
2534+
);
2535+
2536+
if (hasPendingMutations) {
2537+
// NEW: Use mutation-based save path
2538+
const parser = new XMLParser({
2539+
ignoreAttributes: false,
2540+
attributeNamePrefix: '@_',
2541+
});
2542+
const gridBuilder = new XMLBuilder({
2543+
ignoreAttributes: false,
2544+
format: true,
2545+
indentBy: ' ',
2546+
suppressEmptyNode: true,
2547+
suppressBooleanAttributes: false,
2548+
});
2549+
2550+
await GridsetSaveHandler.saveWithMutations(
2551+
tree,
2552+
originalZip,
2553+
outputZip,
2554+
parser,
2555+
gridBuilder,
2556+
(page) => this.createBasicGridXml(page)
2557+
);
2558+
2559+
// Copy remaining files
2560+
for (const entry of originalZip.getEntries()) {
2561+
if (entry.isDirectory) continue;
2562+
if (!outputZip.getEntry(entry.entryName)) {
2563+
outputZip.addFile(entry.entryName, entry.getData());
2564+
}
2565+
}
2566+
2567+
const outputBuffer = outputZip.toBuffer();
2568+
await writeBinaryToPath(outputPath, outputBuffer);
2569+
return;
2570+
}
2571+
2572+
// LEGACY: Original position-based logic continues below...
25302573
// Create a map of pages by name for easy lookup
25312574
const pagesByName = new Map<string, AACPage>();
25322575
for (const page of Object.values(tree.pages)) {

0 commit comments

Comments
 (0)