diff --git a/src/processors/obfProcessor.ts b/src/processors/obfProcessor.ts index af78e8c..8ec2327 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; } @@ -223,6 +241,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 +268,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 +320,7 @@ class ObfProcessor extends BaseProcessor { parentId: null, locale: boardData.locale, descriptionHtml: boardData.description_html, - images: boardData.images, + images, sounds: boardData.sounds, }); @@ -397,7 +423,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 +494,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,20 +531,34 @@ 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; + 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 { + this.zipFile = await this.options.zipAdapter(filePathOrBuffer); + } catch (err) { + console.error('[OBF] Error loading ZIP:', err); + throw err; + } } // 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 = this.zipFile.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')); @@ -627,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, @@ -675,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 : [], }; } @@ -721,8 +774,9 @@ class ObfProcessor extends BaseProcessor { return await readBinaryFromInput(outputPath); } - async saveFromTree(tree: AACTree, outputPath: string): Promise { - const { writeTextToPath, writeBinaryToPath, pathExists } = this.options.fileAdapter; + async saveFromTree(tree: AACTree, outputPath: string, embedData = false): Promise { + const { writeTextToPath, writeBinaryToPath, pathExists, mkDir, 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]; @@ -730,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 { @@ -758,13 +817,24 @@ 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 (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, + this.options.fileAdapter + ); + 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); + for (const file of files) { + const filePath = join(outputPath, file.name); + await writeBinaryToPath(filePath, file.data); + } + } } } 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,