From 49b49286c9df2a5f21c0367618c8828c215ad5da Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Mon, 20 Apr 2026 15:33:26 +0100 Subject: [PATCH 01/14] Added directory support to loadIntoTree --- src/processors/obfProcessor.ts | 53 +++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index e38bd5e..14e7a91 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -397,7 +397,8 @@ class ObfProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { readBinaryFromInput, readTextFromInput } = this.options.fileAdapter; + const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory } = + this.options.fileAdapter; // Detailed logging for debugging input const bufferLength = typeof filePathOrBuffer === 'string' @@ -467,18 +468,22 @@ class ObfProcessor extends BaseProcessor { } } - // Detect likely zip signature first - async function isLikelyZip(input: ProcessorInput): Promise { - if (typeof input === 'string') { - const lowered = input.toLowerCase(); - return lowered.endsWith('.zip') || lowered.endsWith('.obz'); + // Determine if input is ZIP, directory, or OBF JSON string/buffer + let fileType: 'obf' | 'zip' | 'dir' = 'obf'; + if (typeof filePathOrBuffer !== 'string') { + const bytes = await readBinaryFromInput(filePathOrBuffer); + if (bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b) fileType = 'zip'; + } else { + if (await isDirectory(filePathOrBuffer)) { + fileType = 'dir'; + } else { + const lowered = filePathOrBuffer.toLowerCase(); + if (lowered.endsWith('.zip') || lowered.endsWith('.obz')) fileType = 'zip'; } - const bytes = await readBinaryFromInput(input); - return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b; } // Check if input is a buffer or string that parses as OBF JSON; throw if neither JSON nor ZIP - if (!(await isLikelyZip(filePathOrBuffer))) { + if (fileType === 'obf') { const asJson = await tryParseObfJson(filePathOrBuffer); if (!asJson) throw new Error('Invalid OBF content: not JSON and not ZIP'); console.log('[OBF] Detected buffer/string as OBF JSON'); @@ -500,11 +505,25 @@ class ObfProcessor extends BaseProcessor { return tree; } - try { - this.zipFile = await this.options.zipAdapter(filePathOrBuffer); - } catch (err) { - console.error('[OBF] Error loading ZIP:', err); - throw err; + let adapter = { + listFiles: async (): Promise => { + return await listDir(filePathOrBuffer as string); + }, + readFile: async (name: string) => { + return await readBinaryFromInput(join(filePathOrBuffer as string, name)); + }, + }; + if (fileType === 'zip') { + try { + const zipAdapter = await this.options.zipAdapter(filePathOrBuffer); + adapter = { + ...zipAdapter, + listFiles: async () => Promise.resolve(zipAdapter.listFiles()), + }; + } catch (err) { + console.error('[OBF] Error loading ZIP:', err); + throw err; + } } // Store the ZIP file reference for image extraction @@ -513,14 +532,14 @@ class ObfProcessor extends BaseProcessor { console.log('[OBF] Detected zip archive, extracting .obf files'); // List manifest and OBF files - const filesInZip = this.zipFile.listFiles(); + const filesInZip = await adapter.listFiles(); const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json'); let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf')); // Attempt to read manifest if (manifestFile && manifestFile.length === 1) { try { - const content = await this.zipFile.readFile(manifestFile[0]); + const content = await adapter.readFile(manifestFile[0]); const data = decodeText(content); const str = typeof data === 'string' ? data : await readTextFromInput(data); if (!str.trim()) throw new Error('Manifest object missing'); @@ -545,7 +564,7 @@ class ObfProcessor extends BaseProcessor { // Process each .obf entry for (const entryName of obfEntries) { try { - const content = await this.zipFile.readFile(entryName); + const content = await adapter.readFile(entryName); const boardData = await tryParseObfJson(decodeText(content)); if (boardData) { const page = await this.processBoard(boardData, entryName, true); From f4d6d6c5643788401e66167804e8d7ab126cad60 Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Mon, 20 Apr 2026 15:37:54 +0100 Subject: [PATCH 02/14] Added directory write support to saveFromTree --- src/processors/obfProcessor.ts | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 14e7a91..92867e4 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -741,7 +741,8 @@ class ObfProcessor extends BaseProcessor { } async saveFromTree(tree: AACTree, outputPath: string): Promise { - const { writeTextToPath, writeBinaryToPath, pathExists } = this.options.fileAdapter; + const { writeTextToPath, writeBinaryToPath, pathExists, isDirectory, join } = + this.options.fileAdapter; if (outputPath.endsWith('.obf')) { // Save as single OBF JSON file const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0]; @@ -777,13 +778,20 @@ class ObfProcessor extends BaseProcessor { name: 'manifest.json', data: new TextEncoder().encode(JSON.stringify(manifest)), }); - const fileExists = await pathExists(outputPath); - this.zipFile = await this.options.zipAdapter( - fileExists ? outputPath : undefined, - this.options.fileAdapter - ); - const zipData = await this.zipFile.writeFiles(files); - await writeBinaryToPath(outputPath, zipData); + + if (await isDirectory(outputPath)) { + await Promise.all( + files.map((file) => writeBinaryToPath(join(outputPath, file.name), file.data)) + ); + } else { + const fileExists = await pathExists(outputPath); + this.zipFile = await this.options.zipAdapter( + fileExists ? outputPath : undefined, + this.options.fileAdapter + ); + const zipData = await this.zipFile.writeFiles(files); + await writeBinaryToPath(outputPath, zipData); + } } } From a4ffbd48bf27f77247a5e3aa93432fef61d62378 Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Mon, 20 Apr 2026 16:01:11 +0100 Subject: [PATCH 03/14] Assume output is dir if not ending with obf, obz, or zip --- src/processors/obfProcessor.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 92867e4..74ceec5 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -397,7 +397,7 @@ class ObfProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory } = + const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory, pathExists } = this.options.fileAdapter; // Detailed logging for debugging input const bufferLength = @@ -741,7 +741,7 @@ class ObfProcessor extends BaseProcessor { } async saveFromTree(tree: AACTree, outputPath: string): Promise { - const { writeTextToPath, writeBinaryToPath, pathExists, isDirectory, join } = + const { writeTextToPath, writeBinaryToPath, pathExists, mkDir, join } = this.options.fileAdapter; if (outputPath.endsWith('.obf')) { // Save as single OBF JSON file @@ -779,11 +779,8 @@ class ObfProcessor extends BaseProcessor { data: new TextEncoder().encode(JSON.stringify(manifest)), }); - if (await isDirectory(outputPath)) { - await Promise.all( - files.map((file) => writeBinaryToPath(join(outputPath, file.name), file.data)) - ); - } else { + if (outputPath.endsWith('.obz') || outputPath.endsWith('.zip')) { + console.log('[OBF] Saving to ZIP file:', outputPath); const fileExists = await pathExists(outputPath); this.zipFile = await this.options.zipAdapter( fileExists ? outputPath : undefined, @@ -791,6 +788,12 @@ class ObfProcessor extends BaseProcessor { ); const zipData = await this.zipFile.writeFiles(files); await writeBinaryToPath(outputPath, zipData); + } else { + console.log('[OBF] Saving to directory:', outputPath); + if (!(await pathExists(outputPath))) await mkDir(outputPath) + await Promise.all( + files.map((file) => writeBinaryToPath(join(outputPath, file.name), file.data)) + ); } } } From 34e8f94e887260ac4b4679c1cc27ff69e7500b6b Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Mon, 20 Apr 2026 16:26:31 +0100 Subject: [PATCH 04/14] Removed unused var, added type --- src/processors/obfProcessor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 74ceec5..f6fe443 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -397,7 +397,7 @@ class ObfProcessor extends BaseProcessor { } async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise { - const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory, pathExists } = + const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory } = this.options.fileAdapter; // Detailed logging for debugging input const bufferLength = @@ -509,7 +509,7 @@ class ObfProcessor extends BaseProcessor { listFiles: async (): Promise => { return await listDir(filePathOrBuffer as string); }, - readFile: async (name: string) => { + readFile: async (name: string): Promise => { return await readBinaryFromInput(join(filePathOrBuffer as string, name)); }, }; From 038a394136be06e40e7f5d630d1d90e894381365 Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Mon, 20 Apr 2026 16:51:42 +0100 Subject: [PATCH 05/14] Set this.zipFile.readFile to trigger image reading --- src/processors/obfProcessor.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index f6fe443..6b4e5f9 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -505,21 +505,20 @@ class ObfProcessor extends BaseProcessor { return tree; } - let adapter = { - listFiles: async (): Promise => { - return await listDir(filePathOrBuffer as string); - }, + this.zipFile = { readFile: async (name: string): Promise => { return await readBinaryFromInput(join(filePathOrBuffer as string, name)); }, + listFiles: () => { + throw new Error('Not implemented for directory input'); + }, + writeFiles: () => { + throw new Error('Not implemented for directory input'); + }, }; if (fileType === 'zip') { try { - const zipAdapter = await this.options.zipAdapter(filePathOrBuffer); - adapter = { - ...zipAdapter, - listFiles: async () => Promise.resolve(zipAdapter.listFiles()), - }; + this.zipFile = await this.options.zipAdapter(filePathOrBuffer); } catch (err) { console.error('[OBF] Error loading ZIP:', err); throw err; @@ -532,14 +531,15 @@ class ObfProcessor extends BaseProcessor { console.log('[OBF] Detected zip archive, extracting .obf files'); // List manifest and OBF files - const filesInZip = await adapter.listFiles(); + const filesInZip = + fileType === 'zip' ? this.zipFile.listFiles() : await listDir(filePathOrBuffer as string); const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json'); let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf')); // Attempt to read manifest if (manifestFile && manifestFile.length === 1) { try { - const content = await adapter.readFile(manifestFile[0]); + const content = await this.zipFile.readFile(manifestFile[0]); const data = decodeText(content); const str = typeof data === 'string' ? data : await readTextFromInput(data); if (!str.trim()) throw new Error('Manifest object missing'); @@ -564,7 +564,7 @@ class ObfProcessor extends BaseProcessor { // Process each .obf entry for (const entryName of obfEntries) { try { - const content = await adapter.readFile(entryName); + const content = await this.zipFile.readFile(entryName); const boardData = await tryParseObfJson(decodeText(content)); if (boardData) { const page = await this.processBoard(boardData, entryName, true); @@ -790,7 +790,7 @@ class ObfProcessor extends BaseProcessor { await writeBinaryToPath(outputPath, zipData); } else { console.log('[OBF] Saving to directory:', outputPath); - if (!(await pathExists(outputPath))) await mkDir(outputPath) + if (!(await pathExists(outputPath))) await mkDir(outputPath); await Promise.all( files.map((file) => writeBinaryToPath(join(outputPath, file.name), file.data)) ); From 92af3af09a6c4a5afb020bc8ebec1df08c3a180c Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Tue, 21 Apr 2026 09:51:09 +0100 Subject: [PATCH 06/14] Write file sequentially --- src/processors/obfProcessor.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 6b4e5f9..f1c4523 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -528,7 +528,7 @@ class ObfProcessor extends BaseProcessor { // Store the ZIP file reference for image extraction this.imageCache.clear(); // Clear cache for new file - console.log('[OBF] Detected zip archive, extracting .obf files'); + console.log('[OBF] Detected zip archive or directory, extracting .obf files'); // List manifest and OBF files const filesInZip = @@ -791,9 +791,10 @@ class ObfProcessor extends BaseProcessor { } else { console.log('[OBF] Saving to directory:', outputPath); if (!(await pathExists(outputPath))) await mkDir(outputPath); - await Promise.all( - files.map((file) => writeBinaryToPath(join(outputPath, file.name), file.data)) - ); + for (const file of files) { + const filePath = join(outputPath, file.name); + await writeBinaryToPath(filePath, file.data); + } } } } From cd11eb1191097053ec0fcd72d450071d58206a01 Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Tue, 21 Apr 2026 10:08:30 +0100 Subject: [PATCH 07/14] Save image data to board images array (as per spec) --- src/processors/obfProcessor.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index f1c4523..81d21e1 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -223,6 +223,8 @@ class ObfProcessor extends BaseProcessor { ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || ''; + const images = boardData.images; + const buttons: AACButton[] = await Promise.all( sourceButtons.map(async (btn: ObfButton): Promise => { const semanticAction: AACSemanticAction = btn.load_board @@ -248,11 +250,17 @@ class ObfProcessor extends BaseProcessor { // Resolve image if image_id is present let resolvedImage: string | undefined; let imageBuffer: Buffer | undefined; - if (btn.image_id && boardData.images) { - resolvedImage = - (await this.extractImageAsDataUrl(btn.image_id, boardData.images)) || undefined; - imageBuffer = - (await this.extractImageAsBuffer(btn.image_id, boardData.images)) || undefined; + if (btn.image_id && images) { + resolvedImage = (await this.extractImageAsDataUrl(btn.image_id, images)) || undefined; + imageBuffer = (await this.extractImageAsBuffer(btn.image_id, images)) || undefined; + + // save image data + if (images) { + const imageIndex = images?.findIndex((img: any) => img.id === btn.image_id); + if (imageIndex !== -1) { + images[imageIndex].data = resolvedImage; + } + } } // Build parameters object for Grid3 export compatibility @@ -294,7 +302,7 @@ class ObfProcessor extends BaseProcessor { parentId: null, locale: boardData.locale, descriptionHtml: boardData.description_html, - images: boardData.images, + images, sounds: boardData.sounds, }); From fca5e030997ecbb32c973b0c991fb8aa91bb1291 Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Tue, 21 Apr 2026 10:54:30 +0100 Subject: [PATCH 08/14] Load image data into data attribute. Optionally embed data attribute when saving. --- src/processors/obfProcessor.ts | 55 ++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 81d21e1..434f053 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -70,6 +70,24 @@ interface ObfGrid { order?: Array>; } +interface ObfImage { + id: string; + data?: string; + path?: string; + url?: string; + width?: number; + height?: number; + content_type?: string; + license?: { + type?: string; + copyright_notice_url?: string; + source_url?: string; + author_name?: string; + author_url?: string; + author_email?: string; + }; +} + interface ObfBoard { format?: string; id: string; @@ -79,7 +97,7 @@ interface ObfBoard { description_html?: string; buttons: ObfButton[]; grid?: ObfGrid; - images?: any[]; + images?: ObfImage[]; sounds?: any[]; } @@ -132,7 +150,7 @@ class ObfProcessor extends BaseProcessor { /** * Extract an image from the ZIP file and convert to data URL */ - private async extractImageAsDataUrl(imageId: string, images: any[]): Promise { + private async extractImageAsDataUrl(imageId: string, images: ObfImage[]): Promise { // Check cache first if (this.imageCache.has(imageId)) { return this.imageCache.get(imageId) ?? null; @@ -147,8 +165,8 @@ class ObfProcessor extends BaseProcessor { } // If image has data property, use that - if ((imageData as { data?: string }).data) { - const dataUrl = (imageData as { data: string }).data; + if (imageData.data) { + const dataUrl = imageData.data; this.imageCache.set(imageId, dataUrl); return dataUrl; } @@ -158,7 +176,7 @@ class ObfProcessor extends BaseProcessor { // Images are typically stored in an 'images' folder or root const possiblePaths = [ imageData.path, // Explicit path if provided - `images/${imageData.filename || imageId}`, // Standard images folder + `images/${imageData.path || imageId}`, // Standard images folder imageData.id, // Just the ID ].filter(Boolean); @@ -181,8 +199,8 @@ class ObfProcessor extends BaseProcessor { } // If image has a URL, use that as fallback - if ((imageData as { url?: string }).url) { - const url = (imageData as { url: string }).url; + if (imageData.url) { + const url = imageData.url; this.imageCache.set(imageId, url); return url; } @@ -654,13 +672,21 @@ class ObfProcessor extends BaseProcessor { private createObfBoardFromPage( page: AACPage, fallbackName: string, - metadata?: AACTreeMetadata + metadata?: AACTreeMetadata, + embedData = false ): ObfBoard { const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page); const boardName = metadata?.name && page.id === metadata?.defaultHomePageId ? metadata.name : page.name || fallbackName; + let images: ObfImage[] = Array.isArray(page.images) ? page.images : []; + if (!embedData) { + images = images.map((image) => { + delete image.data; + return image; + }); + } return { format: OBF_FORMAT_VERSION, @@ -702,7 +728,7 @@ class ObfProcessor extends BaseProcessor { hidden: button.visibility === 'Hidden' || false, }; }), - images: Array.isArray(page.images) ? page.images : [], + images, sounds: Array.isArray(page.sounds) ? page.sounds : [], }; } @@ -748,7 +774,7 @@ class ObfProcessor extends BaseProcessor { return await readBinaryFromInput(outputPath); } - async saveFromTree(tree: AACTree, outputPath: string): Promise { + async saveFromTree(tree: AACTree, outputPath: string, embedData = false): Promise { const { writeTextToPath, writeBinaryToPath, pathExists, mkDir, join } = this.options.fileAdapter; if (outputPath.endsWith('.obf')) { @@ -758,12 +784,17 @@ class ObfProcessor extends BaseProcessor { throw new Error('No pages to save'); } - const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata); + const obfBoard = this.createObfBoardFromPage( + rootPage, + 'Exported Board', + tree.metadata, + embedData + ); await writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2)); } else { const getPageFilename = (id: string): string => (id.endsWith('.obf') ? id : `${id}.obf`); const files = Object.values(tree.pages).map((page) => { - const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata); + const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata, embedData); const obfContent = JSON.stringify(obfBoard, null, 2); const name = getPageFilename(page.id); return { From 1678131e1310cda7ce5a8b7a12f9a297375b4f6f Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Tue, 21 Apr 2026 13:19:02 +0100 Subject: [PATCH 09/14] Removed unused export to avoid node:path import --- src/utilities/analytics/morphology/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utilities/analytics/morphology/index.ts b/src/utilities/analytics/morphology/index.ts index 4c888a6..eaa37a3 100644 --- a/src/utilities/analytics/morphology/index.ts +++ b/src/utilities/analytics/morphology/index.ts @@ -1,5 +1,4 @@ export { MorphologyEngine } from './engine'; -export { Grid3VerbsParser } from './grid3VerbsParser'; export { WordFormGenerator } from './wordFormGenerator'; export type { MorphRuleSet, From 7ca1731cf31f5fbd3f41db2c5cc53d81968646db Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Mon, 27 Apr 2026 15:33:00 +0100 Subject: [PATCH 10/14] Use page ID by default instead of path --- src/processors/obfProcessor.ts | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 434f053..5f7339d 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -227,19 +227,11 @@ class ObfProcessor extends BaseProcessor { } } - private async processBoard( - boardData: ObfBoard, - _boardPath: string, - isZipEntry: boolean - ): Promise { + private async processBoard(boardData: ObfBoard, _boardPath: string): Promise { const sourceButtons = boardData.buttons || []; // Calculate page ID first (used to make button IDs unique) - const pageId = isZipEntry - ? _boardPath // Zip entry - use filename to match navigation paths - : boardData?.id - ? String(boardData.id) - : _boardPath?.split(/[/\\]/).pop() || ''; + const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || ''; const images = boardData.images; @@ -471,7 +463,7 @@ class ObfProcessor extends BaseProcessor { const boardData = await tryParseObfJson(content); if (boardData) { console.log('[OBF] Detected .obf file, parsed as JSON'); - const page = await this.processBoard(boardData, filePathOrBuffer, false); + const page = await this.processBoard(boardData, filePathOrBuffer); tree.addPage(page); // Set metadata from root board @@ -513,7 +505,7 @@ class ObfProcessor extends BaseProcessor { const asJson = await tryParseObfJson(filePathOrBuffer); if (!asJson) throw new Error('Invalid OBF content: not JSON and not ZIP'); console.log('[OBF] Detected buffer/string as OBF JSON'); - const page = await this.processBoard(asJson, '[bufferOrString]', false); + const page = await this.processBoard(asJson, '[bufferOrString]'); tree.addPage(page); // Set metadata from root board @@ -593,7 +585,7 @@ class ObfProcessor extends BaseProcessor { const content = await this.zipFile.readFile(entryName); const boardData = await tryParseObfJson(decodeText(content)); if (boardData) { - const page = await this.processBoard(boardData, entryName, true); + const page = await this.processBoard(boardData, entryName); tree.addPage(page); // Set metadata if not already set (use first board as reference) From 0abc7ecc947712cc3f81e7121b34149f63a1856c Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Tue, 28 Apr 2026 10:40:13 +0100 Subject: [PATCH 11/14] Save .obf path in tree metadata and use when saving tree --- src/processors/obfProcessor.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 5f7339d..29d070a 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -595,9 +595,12 @@ class ObfProcessor extends BaseProcessor { tree.metadata.description = boardData.description_html; tree.metadata.locale = boardData.locale; tree.metadata.id = boardData.id; + tree.metadata._obfPagePaths = { [page.id]: entryName }; if (boardData.url) tree.metadata.url = boardData.url; if (boardData.locale) tree.metadata.languages = [boardData.locale]; tree.rootId = page.id; + } else { + tree.metadata._obfPagePaths[page.id] = entryName; } } else { console.warn('[OBF] Skipped entry (not valid OBF JSON):', entryName); @@ -784,7 +787,12 @@ class ObfProcessor extends BaseProcessor { ); await writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2)); } else { - const getPageFilename = (id: string): string => (id.endsWith('.obf') ? id : `${id}.obf`); + const getPageFilename = (id: string): string => { + if (tree.metadata._obfPagePaths && id in tree.metadata._obfPagePaths) + return tree.metadata._obfPagePaths[id] as string; + if (id.endsWith('.obf')) return id; + return `${id}.obf`; + }; const files = Object.values(tree.pages).map((page) => { const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata, embedData); const obfContent = JSON.stringify(obfBoard, null, 2); From 6d9fd957fa990f1a18672b4b7ac20c7ab93cfd99 Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Tue, 28 Apr 2026 12:05:32 +0100 Subject: [PATCH 12/14] Revert "Use page ID by default instead of path" This reverts commit 7ca1731cf31f5fbd3f41db2c5cc53d81968646db. --- src/processors/obfProcessor.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index 37bb731..b9e8a2e 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -227,11 +227,19 @@ class ObfProcessor extends BaseProcessor { } } - private async processBoard(boardData: ObfBoard, _boardPath: string): Promise { + private async processBoard( + boardData: ObfBoard, + _boardPath: string, + isZipEntry: boolean + ): Promise { const sourceButtons = boardData.buttons || []; // Calculate page ID first (used to make button IDs unique) - const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || ''; + const pageId = isZipEntry + ? _boardPath // Zip entry - use filename to match navigation paths + : boardData?.id + ? String(boardData.id) + : _boardPath?.split(/[/\\]/).pop() || ''; const images = boardData.images; @@ -463,7 +471,7 @@ class ObfProcessor extends BaseProcessor { const boardData = await tryParseObfJson(content); if (boardData) { console.log('[OBF] Detected .obf file, parsed as JSON'); - const page = await this.processBoard(boardData, filePathOrBuffer); + const page = await this.processBoard(boardData, filePathOrBuffer, false); tree.addPage(page); // Set metadata from root board @@ -505,7 +513,7 @@ class ObfProcessor extends BaseProcessor { const asJson = await tryParseObfJson(filePathOrBuffer); if (!asJson) throw new Error('Invalid OBF content: not JSON and not ZIP'); console.log('[OBF] Detected buffer/string as OBF JSON'); - const page = await this.processBoard(asJson, '[bufferOrString]'); + const page = await this.processBoard(asJson, '[bufferOrString]', false); tree.addPage(page); // Set metadata from root board @@ -585,7 +593,7 @@ class ObfProcessor extends BaseProcessor { const content = await this.zipFile.readFile(entryName); const boardData = await tryParseObfJson(decodeText(content)); if (boardData) { - const page = await this.processBoard(boardData, entryName); + const page = await this.processBoard(boardData, entryName, true); tree.addPage(page); // Set metadata if not already set (use first board as reference) From 0b762a7ce5b78eaae338d968a76083473ac4268d Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Tue, 28 Apr 2026 13:23:04 +0100 Subject: [PATCH 13/14] Moved getPageFilename to class level --- src/processors/obfProcessor.ts | 47 ++++++++++++++++------------------ test/obfProcessor.test.ts | 1 + 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index b9e8a2e..85d0603 100644 --- a/src/processors/obfProcessor.ts +++ b/src/processors/obfProcessor.ts @@ -227,19 +227,18 @@ class ObfProcessor extends BaseProcessor { } } - private async processBoard( - boardData: ObfBoard, - _boardPath: string, - isZipEntry: boolean - ): Promise { + private getPageFilename(id: string, metadata: any): string { + if (metadata._obfPagePaths && id in metadata._obfPagePaths) + return metadata._obfPagePaths[id] as string; + if (id.endsWith('.obf')) return id; + return `${id}.obf`; + } + + private async processBoard(boardData: ObfBoard, _boardPath: string): Promise { const sourceButtons = boardData.buttons || []; // Calculate page ID first (used to make button IDs unique) - const pageId = isZipEntry - ? _boardPath // Zip entry - use filename to match navigation paths - : boardData?.id - ? String(boardData.id) - : _boardPath?.split(/[/\\]/).pop() || ''; + const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || ''; const images = boardData.images; @@ -471,7 +470,7 @@ class ObfProcessor extends BaseProcessor { const boardData = await tryParseObfJson(content); if (boardData) { console.log('[OBF] Detected .obf file, parsed as JSON'); - const page = await this.processBoard(boardData, filePathOrBuffer, false); + const page = await this.processBoard(boardData, filePathOrBuffer); tree.addPage(page); // Set metadata from root board @@ -513,7 +512,7 @@ class ObfProcessor extends BaseProcessor { const asJson = await tryParseObfJson(filePathOrBuffer); if (!asJson) throw new Error('Invalid OBF content: not JSON and not ZIP'); console.log('[OBF] Detected buffer/string as OBF JSON'); - const page = await this.processBoard(asJson, '[bufferOrString]', false); + const page = await this.processBoard(asJson, '[bufferOrString]'); tree.addPage(page); // Set metadata from root board @@ -593,7 +592,7 @@ class ObfProcessor extends BaseProcessor { const content = await this.zipFile.readFile(entryName); const boardData = await tryParseObfJson(decodeText(content)); if (boardData) { - const page = await this.processBoard(boardData, entryName, true); + const page = await this.processBoard(boardData, entryName); tree.addPage(page); // Set metadata if not already set (use first board as reference) @@ -795,16 +794,10 @@ class ObfProcessor extends BaseProcessor { ); await writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2)); } else { - const getPageFilename = (id: string): string => { - if (tree.metadata._obfPagePaths && id in tree.metadata._obfPagePaths) - return tree.metadata._obfPagePaths[id] as string; - if (id.endsWith('.obf')) return id; - return `${id}.obf`; - }; const files = Object.values(tree.pages).map((page) => { const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata, embedData); const obfContent = JSON.stringify(obfBoard, null, 2); - const name = getPageFilename(page.id); + const name = this.getPageFilename(page.id, tree.metadata); return { name, data: new TextEncoder().encode(obfContent), @@ -815,7 +808,10 @@ class ObfProcessor extends BaseProcessor { root: tree.metadata.defaultHomePageId, paths: { boards: Object.fromEntries( - Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)]) + Object.entries(tree.pages).map(([id, page]) => [ + id, + this.getPageFilename(page.id, tree.metadata), + ]) ), images: {}, //TODO Add support for saving images as files sounds: {}, //TODO Add support for saving sounds as files @@ -874,8 +870,6 @@ class ObfProcessor extends BaseProcessor { const originalZip = new AdmZip(originalPath); const outputZip = new AdmZip(); - const getPageFilename = (id: string): string => (id.endsWith('.obf') ? id : `${id}.obf`); - // Track which .obf files we're modifying const modifiedObfFiles = new Set(); @@ -883,7 +877,7 @@ class ObfProcessor extends BaseProcessor { const newObfFiles = new Map(); for (const page of Object.values(tree.pages)) { - const obfFilename = getPageFilename(page.id); + const obfFilename = this.getPageFilename(page.id, tree.metadata); modifiedObfFiles.add(obfFilename); const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata); @@ -900,7 +894,10 @@ class ObfProcessor extends BaseProcessor { root: tree.metadata.defaultHomePageId, paths: { boards: Object.fromEntries( - Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)]) + Object.entries(tree.pages).map(([id, page]) => [ + id, + this.getPageFilename(page.id, tree.metadata), + ]) ), images: {}, sounds: {}, diff --git a/test/obfProcessor.test.ts b/test/obfProcessor.test.ts index 7a25646..50c070d 100644 --- a/test/obfProcessor.test.ts +++ b/test/obfProcessor.test.ts @@ -89,6 +89,7 @@ describe('OBFProcessor', () => { const savedTree = await processor.loadIntoTree(tempOutputPath); // Verify the saved tree has the same pages + console.log("page keys", Object.keys(savedTree.pages)) expect(Object.keys(savedTree.pages).length).toBe(originalPageCount); expect(savedTree.rootId).toBe(tree.rootId); }); From cf350c5777d8957114bc26f92c5d4d363ada456f Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Tue, 28 Apr 2026 13:54:21 +0100 Subject: [PATCH 14/14] Remove debug logging --- test/obfProcessor.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/obfProcessor.test.ts b/test/obfProcessor.test.ts index 50c070d..7a25646 100644 --- a/test/obfProcessor.test.ts +++ b/test/obfProcessor.test.ts @@ -89,7 +89,6 @@ describe('OBFProcessor', () => { const savedTree = await processor.loadIntoTree(tempOutputPath); // Verify the saved tree has the same pages - console.log("page keys", Object.keys(savedTree.pages)) expect(Object.keys(savedTree.pages).length).toBe(originalPageCount); expect(savedTree.rootId).toBe(tree.rootId); });