Skip to content
Merged
154 changes: 112 additions & 42 deletions src/processors/obfProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,24 @@ interface ObfGrid {
order?: Array<Array<string | number | null>>;
}

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;
Expand All @@ -79,7 +97,7 @@ interface ObfBoard {
description_html?: string;
buttons: ObfButton[];
grid?: ObfGrid;
images?: any[];
images?: ObfImage[];
sounds?: any[];
}

Expand Down Expand Up @@ -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<string | null> {
private async extractImageAsDataUrl(imageId: string, images: ObfImage[]): Promise<string | null> {
// Check cache first
if (this.imageCache.has(imageId)) {
return this.imageCache.get(imageId) ?? null;
Expand All @@ -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;
}
Expand All @@ -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);

Expand All @@ -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;
}
Expand Down Expand Up @@ -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<AACButton> => {
const semanticAction: AACSemanticAction = btn.load_board
Expand All @@ -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
Expand Down Expand Up @@ -294,7 +320,7 @@ class ObfProcessor extends BaseProcessor {
parentId: null,
locale: boardData.locale,
descriptionHtml: boardData.description_html,
images: boardData.images,
images,
sounds: boardData.sounds,
});

Expand Down Expand Up @@ -397,7 +423,8 @@ class ObfProcessor extends BaseProcessor {
}

async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise<AACTree> {
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'
Expand Down Expand Up @@ -467,18 +494,22 @@ class ObfProcessor extends BaseProcessor {
}
}

// Detect likely zip signature first
async function isLikelyZip(input: ProcessorInput): Promise<boolean> {
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');
Expand All @@ -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<Uint8Array> => {
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'));

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 : [],
};
}
Expand Down Expand Up @@ -721,21 +774,27 @@ class ObfProcessor extends BaseProcessor {
return await readBinaryFromInput(outputPath);
}

async saveFromTree(tree: AACTree, outputPath: string): Promise<void> {
const { writeTextToPath, writeBinaryToPath, pathExists } = this.options.fileAdapter;
async saveFromTree(tree: AACTree, outputPath: string, embedData = false): Promise<void> {
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];
if (!rootPage) {
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 {
Expand All @@ -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);
}
}
}
}

Expand Down
1 change: 0 additions & 1 deletion src/utilities/analytics/morphology/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
export { MorphologyEngine } from './engine';
export { Grid3VerbsParser } from './grid3VerbsParser';
export { WordFormGenerator } from './wordFormGenerator';
export type {
MorphRuleSet,
Expand Down
Loading