From 49b49286c9df2a5f21c0367618c8828c215ad5da Mon Sep 17 00:00:00 2001 From: Chris Baume Date: Mon, 20 Apr 2026 15:33:26 +0100 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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,