Skip to content

Commit 55ff73e

Browse files
committed
Preserve assets when saving modified trees
Add saveModifiedTree methods to GridsetProcessor and ObfProcessor to update only page files inside package archives while preserving original settings, images and other assets. Both implementations use adm-zip to read the original archive and write a new archive that replaces only modified grid.xml (Grids/*/grid.xml) or .obf/manifest entries, handling empty trees by copying the original. GridsetProcessor collects and deduplicates styles, builds grid.xml via XMLBuilder, and emits updated grid files; ObfProcessor generates per-page .obf boards and an updated manifest (skipping single-file .obf outputs and falling back to saveFromTree). Files are written via the configured fileAdapter.
1 parent 093c978 commit 55ff73e

2 files changed

Lines changed: 232 additions & 0 deletions

File tree

src/processors/gridsetProcessor.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2494,6 +2494,152 @@ class GridsetProcessor extends BaseProcessor {
24942494
};
24952495
}
24962496

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

src/processors/obfProcessor.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -768,6 +768,92 @@ class ObfProcessor extends BaseProcessor {
768768
}
769769
}
770770

771+
/**
772+
* Save a modified tree while preserving all original files (images, sounds, assets)
773+
* This method only updates the .obf files for pages in the tree, keeping everything else intact.
774+
*
775+
* @param originalPath - Path to the original OBF/OBZ file
776+
* @param tree - Modified AACTree with pages to save
777+
* @param outputPath - Path where the modified file should be saved
778+
*/
779+
async saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void> {
780+
const { writeBinaryToPath, readBinaryFromInput } = this.options.fileAdapter;
781+
782+
// If output is .obf (single file), use regular save
783+
if (outputPath.endsWith('.obf')) {
784+
await this.saveFromTree(tree, outputPath);
785+
return;
786+
}
787+
788+
if (Object.keys(tree.pages).length === 0) {
789+
// Empty tree, just copy the original
790+
const originalBuffer = await readBinaryFromInput(originalPath);
791+
await writeBinaryToPath(outputPath, originalBuffer);
792+
return;
793+
}
794+
795+
const AdmZip = (await import('adm-zip')).default;
796+
const originalZip = new AdmZip(originalPath);
797+
const outputZip = new AdmZip();
798+
799+
const getPageFilename = (id: string): string => (id.endsWith('.obf') ? id : `${id}.obf`);
800+
801+
// Track which .obf files we're modifying
802+
const modifiedObfFiles = new Set<string>();
803+
804+
// Generate new .obf files for pages in the tree
805+
const newObfFiles = new Map<string, string>();
806+
807+
for (const page of Object.values(tree.pages)) {
808+
const obfFilename = getPageFilename(page.id);
809+
modifiedObfFiles.add(obfFilename);
810+
811+
const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
812+
const obfContent = JSON.stringify(obfBoard, null, 2);
813+
newObfFiles.set(obfFilename, obfContent);
814+
}
815+
816+
// Generate updated manifest if we have pages
817+
if (Object.keys(tree.pages).length > 0) {
818+
modifiedObfFiles.add('manifest.json');
819+
820+
const manifest: ObfManifest = {
821+
format: OBF_FORMAT_VERSION,
822+
root: tree.metadata.defaultHomePageId,
823+
paths: {
824+
boards: Object.fromEntries(
825+
Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])
826+
),
827+
images: {},
828+
sounds: {},
829+
},
830+
};
831+
832+
newObfFiles.set('manifest.json', JSON.stringify(manifest));
833+
}
834+
835+
// Copy all files from original zip, replacing modified .obf files
836+
for (const entry of originalZip.getEntries()) {
837+
if (entry.isDirectory) continue;
838+
839+
// Skip .obf files that we're modifying
840+
if (modifiedObfFiles.has(entry.entryName)) {
841+
const newContent = newObfFiles.get(entry.entryName);
842+
if (newContent) {
843+
outputZip.addFile(entry.entryName, Buffer.from(newContent, 'utf8'));
844+
}
845+
continue;
846+
}
847+
848+
// Copy all other files as-is (preserves images, sounds, etc.)
849+
outputZip.addFile(entry.entryName, entry.getData());
850+
}
851+
852+
// Write the output ZIP
853+
const outputBuffer = outputZip.toBuffer();
854+
await writeBinaryToPath(outputPath, outputBuffer);
855+
}
856+
771857
/**
772858
* Extract strings with metadata for aac-tools-platform compatibility
773859
* Uses the generic implementation from BaseProcessor

0 commit comments

Comments
 (0)