Skip to content

Commit 780e2e8

Browse files
committed
Merge branch 'obf-folder' into obf-save-image-files
2 parents 1678131 + 0ef4595 commit 780e2e8

4 files changed

Lines changed: 430 additions & 2 deletions

File tree

src/processors/gridsetProcessor.ts

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,9 +1292,12 @@ class GridsetProcessor extends BaseProcessor {
12921292
break;
12931293

12941294
case 'Jump.ToKeyboard': {
1295-
// Navigate to the set keyboard if we found one in settings
1295+
// Prefer explicit keyboard page metadata when available.
1296+
// Some Gridsets resolve the keyboard page in metadata
1297+
// without preserving tree.keyboardGridName during parse.
12961298
const keyboardGridName = (tree as any).keyboardGridName as string;
1297-
const keyboardPageId = gridNameToIdMap.get(keyboardGridName);
1299+
const keyboardPageId =
1300+
tree.metadata?.defaultKeyboardPageId || gridNameToIdMap.get(keyboardGridName);
12981301
if (keyboardPageId && !navigationTarget) {
12991302
navigationTarget = keyboardPageId;
13001303
}
@@ -1900,6 +1903,7 @@ class GridsetProcessor extends BaseProcessor {
19001903
settingsData?.gridSetSettings?.keyboardGrid ||
19011904
settingsData?.GridsetSettings?.KeyboardGrid;
19021905
if (keyboardGridName && typeof keyboardGridName === 'string') {
1906+
(tree as any).keyboardGridName = keyboardGridName;
19031907
metadata.defaultKeyboardPageId = gridNameToIdMap.get(keyboardGridName);
19041908
}
19051909
}
@@ -1909,6 +1913,24 @@ class GridsetProcessor extends BaseProcessor {
19091913

19101914
// Set metadata on tree
19111915
tree.metadata = metadata;
1916+
if (metadata.defaultKeyboardPageId) {
1917+
Object.values(tree.pages).forEach((page) => {
1918+
page.buttons.forEach((button) => {
1919+
if (
1920+
button?.semanticAction?.platformData?.grid3?.commandId === 'Jump.ToKeyboard' &&
1921+
!button.targetPageId
1922+
) {
1923+
button.targetPageId = metadata.defaultKeyboardPageId;
1924+
if (button.semanticAction) {
1925+
button.semanticAction.targetId = metadata.defaultKeyboardPageId;
1926+
if (button.semanticAction.fallback?.type === 'NAVIGATE') {
1927+
button.semanticAction.fallback.targetPageId = metadata.defaultKeyboardPageId;
1928+
}
1929+
}
1930+
}
1931+
});
1932+
});
1933+
}
19121934

19131935
return tree;
19141936
}
@@ -2494,6 +2516,152 @@ class GridsetProcessor extends BaseProcessor {
24942516
};
24952517
}
24962518

2519+
/**
2520+
* Save a modified tree while preserving all original files (settings, images, assets)
2521+
* This method only updates the grid.xml files for pages in the tree, keeping everything else intact.
2522+
*
2523+
* @param originalPath - Path to the original gridset file
2524+
* @param tree - Modified AACTree with pages to save
2525+
* @param outputPath - Path where the modified gridset should be saved
2526+
*/
2527+
async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void> {
2528+
const { readBinaryFromInput, writeBinaryToPath } = this.options.fileAdapter;
2529+
2530+
if (Object.keys(tree.pages).length === 0) {
2531+
// Empty tree, just copy the original
2532+
const originalBuffer = await readBinaryFromInput(originalPath);
2533+
await writeBinaryToPath(outputPath, originalBuffer);
2534+
return;
2535+
}
2536+
2537+
const AdmZip = (await import('adm-zip')).default;
2538+
const originalZip = new AdmZip(originalPath);
2539+
const outputZip = new AdmZip();
2540+
2541+
// Collect styles from the tree for grid.xml files
2542+
const uniqueStyles = new Map<string, { id: string; style: AACStyle }>();
2543+
let styleIdCounter = 1;
2544+
2545+
const addStyle = (style: AACStyle | undefined): string => {
2546+
if (!style) return '';
2547+
const normalizedStyle: AACStyle = { ...style };
2548+
const styleKey = JSON.stringify(normalizedStyle);
2549+
const existing = uniqueStyles.get(styleKey);
2550+
if (existing) return existing.id;
2551+
2552+
const styleId = `Style${styleIdCounter++}`;
2553+
uniqueStyles.set(styleKey, { id: styleId, style: normalizedStyle });
2554+
return styleId;
2555+
};
2556+
2557+
// Collect all styles from pages and buttons
2558+
Object.values(tree.pages).forEach((page) => {
2559+
addStyle(page.style);
2560+
page.buttons.forEach((button) => {
2561+
addStyle(button.style);
2562+
});
2563+
});
2564+
2565+
// Track which grid files we're modifying
2566+
const modifiedGridFiles = new Set<string>();
2567+
2568+
// Generate grid.xml files for pages in the tree
2569+
const newGridFiles = new Map<string, string>();
2570+
2571+
for (const page of Object.values(tree.pages)) {
2572+
const gridPath = `Grids/${page.name}/grid.xml`;
2573+
modifiedGridFiles.add(gridPath);
2574+
2575+
// Build the grid XML content
2576+
const gridData = {
2577+
Grid: {
2578+
'@_xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
2579+
GridGuid: page.id,
2580+
ColumnDefinitions: this.calculateColumnDefinitions(page),
2581+
RowDefinitions: this.calculateRowDefinitions(page, false),
2582+
AutoContentCommands: '',
2583+
Cells:
2584+
page.buttons.length > 0
2585+
? {
2586+
Cell: this.filterPageButtons(page.buttons).map((button, btnIndex) => {
2587+
const buttonStyleId = button.style ? addStyle(button.style) : '';
2588+
const position = this.findButtonPosition(page, button, btnIndex);
2589+
2590+
const captionAndImage: Record<string, unknown> = {
2591+
Caption: button.label || '',
2592+
};
2593+
2594+
// Handle image references
2595+
if (button.image) {
2596+
captionAndImage.Image = `${button.image}`;
2597+
}
2598+
2599+
const cell: Record<string, unknown> = {
2600+
'@_Column': position.x,
2601+
'@_Row': position.y,
2602+
captionAndImage,
2603+
};
2604+
2605+
if (position.columnSpan > 1) {
2606+
cell['@_ColumnSpan'] = position.columnSpan;
2607+
}
2608+
if (position.rowSpan > 1) {
2609+
cell['@_RowSpan'] = position.rowSpan;
2610+
}
2611+
2612+
if (buttonStyleId) {
2613+
cell.CellStyle = buttonStyleId;
2614+
}
2615+
2616+
if (button.message && button.message !== button.label) {
2617+
// Use spoken message if different from label
2618+
const spoken = button.message;
2619+
const cellContent: Record<string, unknown> = {
2620+
spoken,
2621+
type: 'text',
2622+
};
2623+
cell['ContentCell'] = cellContent;
2624+
}
2625+
2626+
return cell;
2627+
}),
2628+
}
2629+
: undefined,
2630+
},
2631+
};
2632+
2633+
const gridBuilder = new XMLBuilder({
2634+
ignoreAttributes: false,
2635+
format: true,
2636+
indentBy: ' ',
2637+
suppressEmptyNode: true,
2638+
});
2639+
2640+
newGridFiles.set(gridPath, gridBuilder.build(gridData));
2641+
}
2642+
2643+
// Copy all files from original zip, replacing modified grid files
2644+
for (const entry of originalZip.getEntries()) {
2645+
if (entry.isDirectory) continue;
2646+
2647+
// Skip grid.xml files that we're modifying
2648+
if (modifiedGridFiles.has(entry.entryName)) {
2649+
const newContent = newGridFiles.get(entry.entryName);
2650+
if (newContent) {
2651+
outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
2652+
}
2653+
continue;
2654+
}
2655+
2656+
// Copy all other files as-is
2657+
outputZip.addFile(entry.entryName, entry.getData());
2658+
}
2659+
2660+
// Write the output ZIP
2661+
const outputBuffer = outputZip.toBuffer();
2662+
await writeBinaryToPath(outputPath, outputBuffer);
2663+
}
2664+
24972665
// Helper method to find button position with span information
24982666
private findButtonPosition(
24992667
page: AACPage,

src/processors/obfProcessor.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,92 @@ class ObfProcessor extends BaseProcessor {
838838
}
839839
}
840840

841+
/**
842+
* Save a modified tree while preserving all original files (images, sounds, assets)
843+
* This method only updates the .obf files for pages in the tree, keeping everything else intact.
844+
*
845+
* @param originalPath - Path to the original OBF/OBZ file
846+
* @param tree - Modified AACTree with pages to save
847+
* @param outputPath - Path where the modified file should be saved
848+
*/
849+
async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void> {
850+
const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter;
851+
852+
// If output is .obf (single file), use regular save
853+
if (outputPath.endsWith('.obf')) {
854+
await this.saveFromTree(tree, outputPath);
855+
return;
856+
}
857+
858+
if (Object.keys(tree.pages).length === 0) {
859+
// Empty tree, just copy the original
860+
const originalBuffer = await readBinaryFromInput(originalPath);
861+
await writeBinaryToPath(outputPath, originalBuffer);
862+
return;
863+
}
864+
865+
const AdmZip = (await import('adm-zip')).default;
866+
const originalZip = new AdmZip(originalPath);
867+
const outputZip = new AdmZip();
868+
869+
const getPageFilename = (id: string): string => (id.endsWith('.obf') ? id : `${id}.obf`);
870+
871+
// Track which .obf files we're modifying
872+
const modifiedObfFiles = new Set<string>();
873+
874+
// Generate new .obf files for pages in the tree
875+
const newObfFiles = new Map<string, string>();
876+
877+
for (const page of Object.values(tree.pages)) {
878+
const obfFilename = getPageFilename(page.id);
879+
modifiedObfFiles.add(obfFilename);
880+
881+
const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
882+
const obfContent = JSON.stringify(obfBoard, null, 2);
883+
newObfFiles.set(obfFilename, obfContent);
884+
}
885+
886+
// Generate updated manifest if we have pages
887+
if (Object.keys(tree.pages).length > 0) {
888+
modifiedObfFiles.add('manifest.json');
889+
890+
const manifest: ObfManifest = {
891+
format: OBF_FORMAT_VERSION,
892+
root: tree.metadata.defaultHomePageId,
893+
paths: {
894+
boards: Object.fromEntries(
895+
Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])
896+
),
897+
images: {},
898+
sounds: {},
899+
},
900+
};
901+
902+
newObfFiles.set('manifest.json', JSON.stringify(manifest));
903+
}
904+
905+
// Copy all files from original zip, replacing modified .obf files
906+
for (const entry of originalZip.getEntries()) {
907+
if (entry.isDirectory) continue;
908+
909+
// Skip .obf files that we're modifying
910+
if (modifiedObfFiles.has(entry.entryName)) {
911+
const newContent = newObfFiles.get(entry.entryName);
912+
if (newContent) {
913+
outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
914+
}
915+
continue;
916+
}
917+
918+
// Copy all other files as-is (preserves images, sounds, etc.)
919+
outputZip.addFile(entry.entryName, entry.getData());
920+
}
921+
922+
// Write the output ZIP
923+
const outputBuffer = outputZip.toBuffer();
924+
await writeBinaryToPath(outputPath, outputBuffer);
925+
}
926+
841927
/**
842928
* Extract strings with metadata for aac-tools-platform compatibility
843929
* Uses the generic implementation from BaseProcessor

test/gridsetProcessor.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,91 @@ describe('GridsetProcessor', () => {
8383
}
8484
});
8585
});
86+
87+
describe('saveModifiedTree', () => {
88+
const tempOutputPath = path.join(__dirname, 'temp_gridset_modified.gridset');
89+
const tempSaveFromTreePath = path.join(__dirname, 'temp_gridset_saveFromTree.gridset');
90+
91+
afterEach(async () => {
92+
if (fs.existsSync(tempOutputPath)) {
93+
fs.unlinkSync(tempOutputPath);
94+
}
95+
if (fs.existsSync(tempSaveFromTreePath)) {
96+
fs.unlinkSync(tempSaveFromTreePath);
97+
}
98+
});
99+
100+
it('should preserve original file size better than saveFromTree', async () => {
101+
const processor = new GridsetProcessor();
102+
103+
// Load the original file
104+
const fileBuffer = fs.readFileSync(exampleFile);
105+
const tree = await processor.loadIntoTree(fileBuffer);
106+
const originalSize = fileBuffer.length;
107+
108+
// Save using saveModifiedTree
109+
await processor.saveModifiedTree(exampleFile, tree, tempOutputPath);
110+
const modifiedSize = fs.statSync(tempOutputPath).size;
111+
112+
// Save using saveFromTree for comparison
113+
await processor.saveFromTree(tree, tempSaveFromTreePath);
114+
const saveFromTreeSize = fs.statSync(tempSaveFromTreePath).size;
115+
116+
// saveModifiedTree should preserve file size much better than saveFromTree
117+
expect(modifiedSize).toBeGreaterThan(saveFromTreeSize);
118+
119+
// saveModifiedTree should be at least 80% of original size (preserves most assets)
120+
expect(modifiedSize / originalSize).toBeGreaterThan(0.8);
121+
});
122+
123+
it('should produce a valid loadable gridset', async () => {
124+
const processor = new GridsetProcessor();
125+
126+
// Load the original file
127+
const fileBuffer = fs.readFileSync(exampleFile);
128+
const tree = await processor.loadIntoTree(fileBuffer);
129+
const originalPageCount = Object.keys(tree.pages).length;
130+
131+
// Save using saveModifiedTree
132+
await processor.saveModifiedTree(exampleFile, tree, tempOutputPath);
133+
134+
// Load the saved file
135+
const savedBuffer = fs.readFileSync(tempOutputPath);
136+
const savedTree = await processor.loadIntoTree(savedBuffer);
137+
138+
// Verify the saved tree has the same pages
139+
expect(Object.keys(savedTree.pages).length).toBe(originalPageCount);
140+
expect(savedTree.rootId).toBe(tree.rootId);
141+
});
142+
143+
it('should handle empty tree by copying original', async () => {
144+
const processor = new GridsetProcessor();
145+
146+
// Create an empty tree
147+
const emptyTree: AACTree = {
148+
pages: {},
149+
rootId: null,
150+
toolbarId: null,
151+
dashboardId: null,
152+
metadata: {},
153+
addPage() {
154+
throw new Error('Not implemented');
155+
},
156+
getPage() {
157+
return undefined;
158+
},
159+
traverse() {
160+
// Empty - nothing to traverse
161+
},
162+
};
163+
164+
// Save using saveModifiedTree
165+
await processor.saveModifiedTree(exampleFile, emptyTree, tempOutputPath);
166+
167+
// Verify the file was copied (same size)
168+
const originalSize = fs.statSync(exampleFile).size;
169+
const copiedSize = fs.statSync(tempOutputPath).size;
170+
expect(copiedSize).toBe(originalSize);
171+
});
172+
});
86173
});

0 commit comments

Comments
 (0)