Skip to content

Commit bc8b008

Browse files
authored
Merge pull request #39 from sonicbaume/obf-use-page-ids
Retain original page IDs in OBF
2 parents 1d54eb1 + cf350c5 commit bc8b008

2 files changed

Lines changed: 137 additions & 63 deletions

File tree

src/processors/obfProcessor.ts

Lines changed: 137 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,24 @@ interface ObfGrid {
7070
order?: Array<Array<string | number | null>>;
7171
}
7272

73+
interface ObfImage {
74+
id: string;
75+
data?: string;
76+
path?: string;
77+
url?: string;
78+
width?: number;
79+
height?: number;
80+
content_type?: string;
81+
license?: {
82+
type?: string;
83+
copyright_notice_url?: string;
84+
source_url?: string;
85+
author_name?: string;
86+
author_url?: string;
87+
author_email?: string;
88+
};
89+
}
90+
7391
interface ObfBoard {
7492
format?: string;
7593
id: string;
@@ -79,7 +97,7 @@ interface ObfBoard {
7997
description_html?: string;
8098
buttons: ObfButton[];
8199
grid?: ObfGrid;
82-
images?: any[];
100+
images?: ObfImage[];
83101
sounds?: any[];
84102
}
85103

@@ -132,7 +150,7 @@ class ObfProcessor extends BaseProcessor {
132150
/**
133151
* Extract an image from the ZIP file and convert to data URL
134152
*/
135-
private async extractImageAsDataUrl(imageId: string, images: any[]): Promise<string | null> {
153+
private async extractImageAsDataUrl(imageId: string, images: ObfImage[]): Promise<string | null> {
136154
// Check cache first
137155
if (this.imageCache.has(imageId)) {
138156
return this.imageCache.get(imageId) ?? null;
@@ -147,8 +165,8 @@ class ObfProcessor extends BaseProcessor {
147165
}
148166

149167
// If image has data property, use that
150-
if ((imageData as { data?: string }).data) {
151-
const dataUrl = (imageData as { data: string }).data;
168+
if (imageData.data) {
169+
const dataUrl = imageData.data;
152170
this.imageCache.set(imageId, dataUrl);
153171
return dataUrl;
154172
}
@@ -158,7 +176,7 @@ class ObfProcessor extends BaseProcessor {
158176
// Images are typically stored in an 'images' folder or root
159177
const possiblePaths = [
160178
imageData.path, // Explicit path if provided
161-
`images/${imageData.filename || imageId}`, // Standard images folder
179+
`images/${imageData.path || imageId}`, // Standard images folder
162180
imageData.id, // Just the ID
163181
].filter(Boolean);
164182

@@ -181,8 +199,8 @@ class ObfProcessor extends BaseProcessor {
181199
}
182200

183201
// If image has a URL, use that as fallback
184-
if ((imageData as { url?: string }).url) {
185-
const url = (imageData as { url: string }).url;
202+
if (imageData.url) {
203+
const url = imageData.url;
186204
this.imageCache.set(imageId, url);
187205
return url;
188206
}
@@ -209,19 +227,20 @@ class ObfProcessor extends BaseProcessor {
209227
}
210228
}
211229

212-
private async processBoard(
213-
boardData: ObfBoard,
214-
_boardPath: string,
215-
isZipEntry: boolean
216-
): Promise<AACPage> {
230+
private getPageFilename(id: string, metadata: any): string {
231+
if (metadata._obfPagePaths && id in metadata._obfPagePaths)
232+
return metadata._obfPagePaths[id] as string;
233+
if (id.endsWith('.obf')) return id;
234+
return `${id}.obf`;
235+
}
236+
237+
private async processBoard(boardData: ObfBoard, _boardPath: string): Promise<AACPage> {
217238
const sourceButtons = boardData.buttons || [];
218239

219240
// Calculate page ID first (used to make button IDs unique)
220-
const pageId = isZipEntry
221-
? _boardPath // Zip entry - use filename to match navigation paths
222-
: boardData?.id
223-
? String(boardData.id)
224-
: _boardPath?.split(/[/\\]/).pop() || '';
241+
const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || '';
242+
243+
const images = boardData.images;
225244

226245
const buttons: AACButton[] = await Promise.all(
227246
sourceButtons.map(async (btn: ObfButton): Promise<AACButton> => {
@@ -248,11 +267,17 @@ class ObfProcessor extends BaseProcessor {
248267
// Resolve image if image_id is present
249268
let resolvedImage: string | undefined;
250269
let imageBuffer: Buffer | undefined;
251-
if (btn.image_id && boardData.images) {
252-
resolvedImage =
253-
(await this.extractImageAsDataUrl(btn.image_id, boardData.images)) || undefined;
254-
imageBuffer =
255-
(await this.extractImageAsBuffer(btn.image_id, boardData.images)) || undefined;
270+
if (btn.image_id && images) {
271+
resolvedImage = (await this.extractImageAsDataUrl(btn.image_id, images)) || undefined;
272+
imageBuffer = (await this.extractImageAsBuffer(btn.image_id, images)) || undefined;
273+
274+
// save image data
275+
if (images) {
276+
const imageIndex = images?.findIndex((img: any) => img.id === btn.image_id);
277+
if (imageIndex !== -1) {
278+
images[imageIndex].data = resolvedImage;
279+
}
280+
}
256281
}
257282

258283
// Build parameters object for Grid3 export compatibility
@@ -294,7 +319,7 @@ class ObfProcessor extends BaseProcessor {
294319
parentId: null,
295320
locale: boardData.locale,
296321
descriptionHtml: boardData.description_html,
297-
images: boardData.images,
322+
images,
298323
sounds: boardData.sounds,
299324
});
300325

@@ -397,7 +422,8 @@ class ObfProcessor extends BaseProcessor {
397422
}
398423

399424
async loadIntoTree(filePathOrBuffer: ProcessorInput): Promise<AACTree> {
400-
const { readBinaryFromInput, readTextFromInput } = this.options.fileAdapter;
425+
const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory } =
426+
this.options.fileAdapter;
401427
// Detailed logging for debugging input
402428
const bufferLength =
403429
typeof filePathOrBuffer === 'string'
@@ -444,7 +470,7 @@ class ObfProcessor extends BaseProcessor {
444470
const boardData = await tryParseObfJson(content);
445471
if (boardData) {
446472
console.log('[OBF] Detected .obf file, parsed as JSON');
447-
const page = await this.processBoard(boardData, filePathOrBuffer, false);
473+
const page = await this.processBoard(boardData, filePathOrBuffer);
448474
tree.addPage(page);
449475

450476
// Set metadata from root board
@@ -467,22 +493,26 @@ class ObfProcessor extends BaseProcessor {
467493
}
468494
}
469495

470-
// Detect likely zip signature first
471-
async function isLikelyZip(input: ProcessorInput): Promise<boolean> {
472-
if (typeof input === 'string') {
473-
const lowered = input.toLowerCase();
474-
return lowered.endsWith('.zip') || lowered.endsWith('.obz');
496+
// Determine if input is ZIP, directory, or OBF JSON string/buffer
497+
let fileType: 'obf' | 'zip' | 'dir' = 'obf';
498+
if (typeof filePathOrBuffer !== 'string') {
499+
const bytes = await readBinaryFromInput(filePathOrBuffer);
500+
if (bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b) fileType = 'zip';
501+
} else {
502+
if (await isDirectory(filePathOrBuffer)) {
503+
fileType = 'dir';
504+
} else {
505+
const lowered = filePathOrBuffer.toLowerCase();
506+
if (lowered.endsWith('.zip') || lowered.endsWith('.obz')) fileType = 'zip';
475507
}
476-
const bytes = await readBinaryFromInput(input);
477-
return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
478508
}
479509

480510
// Check if input is a buffer or string that parses as OBF JSON; throw if neither JSON nor ZIP
481-
if (!(await isLikelyZip(filePathOrBuffer))) {
511+
if (fileType === 'obf') {
482512
const asJson = await tryParseObfJson(filePathOrBuffer);
483513
if (!asJson) throw new Error('Invalid OBF content: not JSON and not ZIP');
484514
console.log('[OBF] Detected buffer/string as OBF JSON');
485-
const page = await this.processBoard(asJson, '[bufferOrString]', false);
515+
const page = await this.processBoard(asJson, '[bufferOrString]');
486516
tree.addPage(page);
487517

488518
// Set metadata from root board
@@ -500,20 +530,34 @@ class ObfProcessor extends BaseProcessor {
500530
return tree;
501531
}
502532

503-
try {
504-
this.zipFile = await this.options.zipAdapter(filePathOrBuffer);
505-
} catch (err) {
506-
console.error('[OBF] Error loading ZIP:', err);
507-
throw err;
533+
this.zipFile = {
534+
readFile: async (name: string): Promise<Uint8Array> => {
535+
return await readBinaryFromInput(join(filePathOrBuffer as string, name));
536+
},
537+
listFiles: () => {
538+
throw new Error('Not implemented for directory input');
539+
},
540+
writeFiles: () => {
541+
throw new Error('Not implemented for directory input');
542+
},
543+
};
544+
if (fileType === 'zip') {
545+
try {
546+
this.zipFile = await this.options.zipAdapter(filePathOrBuffer);
547+
} catch (err) {
548+
console.error('[OBF] Error loading ZIP:', err);
549+
throw err;
550+
}
508551
}
509552

510553
// Store the ZIP file reference for image extraction
511554
this.imageCache.clear(); // Clear cache for new file
512555

513-
console.log('[OBF] Detected zip archive, extracting .obf files');
556+
console.log('[OBF] Detected zip archive or directory, extracting .obf files');
514557

515558
// List manifest and OBF files
516-
const filesInZip = this.zipFile.listFiles();
559+
const filesInZip =
560+
fileType === 'zip' ? this.zipFile.listFiles() : await listDir(filePathOrBuffer as string);
517561
const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json');
518562
let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf'));
519563

@@ -548,7 +592,7 @@ class ObfProcessor extends BaseProcessor {
548592
const content = await this.zipFile.readFile(entryName);
549593
const boardData = await tryParseObfJson(decodeText(content));
550594
if (boardData) {
551-
const page = await this.processBoard(boardData, entryName, true);
595+
const page = await this.processBoard(boardData, entryName);
552596
tree.addPage(page);
553597

554598
// Set metadata if not already set (use first board as reference)
@@ -558,9 +602,12 @@ class ObfProcessor extends BaseProcessor {
558602
tree.metadata.description = boardData.description_html;
559603
tree.metadata.locale = boardData.locale;
560604
tree.metadata.id = boardData.id;
605+
tree.metadata._obfPagePaths = { [page.id]: entryName };
561606
if (boardData.url) tree.metadata.url = boardData.url;
562607
if (boardData.locale) tree.metadata.languages = [boardData.locale];
563608
tree.rootId = page.id;
609+
} else {
610+
tree.metadata._obfPagePaths[page.id] = entryName;
564611
}
565612
} else {
566613
console.warn('[OBF] Skipped entry (not valid OBF JSON):', entryName);
@@ -627,13 +674,21 @@ class ObfProcessor extends BaseProcessor {
627674
private createObfBoardFromPage(
628675
page: AACPage,
629676
fallbackName: string,
630-
metadata?: AACTreeMetadata
677+
metadata?: AACTreeMetadata,
678+
embedData = false
631679
): ObfBoard {
632680
const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page);
633681
const boardName =
634682
metadata?.name && page.id === metadata?.defaultHomePageId
635683
? metadata.name
636684
: page.name || fallbackName;
685+
let images: ObfImage[] = Array.isArray(page.images) ? page.images : [];
686+
if (!embedData) {
687+
images = images.map((image) => {
688+
delete image.data;
689+
return image;
690+
});
691+
}
637692

638693
return {
639694
format: OBF_FORMAT_VERSION,
@@ -675,7 +730,7 @@ class ObfProcessor extends BaseProcessor {
675730
hidden: button.visibility === 'Hidden' || false,
676731
};
677732
}),
678-
images: Array.isArray(page.images) ? page.images : [],
733+
images,
679734
sounds: Array.isArray(page.sounds) ? page.sounds : [],
680735
};
681736
}
@@ -721,23 +776,28 @@ class ObfProcessor extends BaseProcessor {
721776
return await readBinaryFromInput(outputPath);
722777
}
723778

724-
async saveFromTree(tree: AACTree, outputPath: string): Promise<void> {
725-
const { writeTextToPath, writeBinaryToPath, pathExists } = this.options.fileAdapter;
779+
async saveFromTree(tree: AACTree, outputPath: string, embedData = false): Promise<void> {
780+
const { writeTextToPath, writeBinaryToPath, pathExists, mkDir, join } =
781+
this.options.fileAdapter;
726782
if (outputPath.endsWith('.obf')) {
727783
// Save as single OBF JSON file
728784
const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0];
729785
if (!rootPage) {
730786
throw new Error('No pages to save');
731787
}
732788

733-
const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata);
789+
const obfBoard = this.createObfBoardFromPage(
790+
rootPage,
791+
'Exported Board',
792+
tree.metadata,
793+
embedData
794+
);
734795
await writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2));
735796
} else {
736-
const getPageFilename = (id: string): string => (id.endsWith('.obf') ? id : `${id}.obf`);
737797
const files = Object.values(tree.pages).map((page) => {
738-
const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
798+
const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata, embedData);
739799
const obfContent = JSON.stringify(obfBoard, null, 2);
740-
const name = getPageFilename(page.id);
800+
const name = this.getPageFilename(page.id, tree.metadata);
741801
return {
742802
name,
743803
data: new TextEncoder().encode(obfContent),
@@ -748,7 +808,10 @@ class ObfProcessor extends BaseProcessor {
748808
root: tree.metadata.defaultHomePageId,
749809
paths: {
750810
boards: Object.fromEntries(
751-
Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])
811+
Object.entries(tree.pages).map(([id, page]) => [
812+
id,
813+
this.getPageFilename(page.id, tree.metadata),
814+
])
752815
),
753816
images: {}, //TODO Add support for saving images as files
754817
sounds: {}, //TODO Add support for saving sounds as files
@@ -758,13 +821,24 @@ class ObfProcessor extends BaseProcessor {
758821
name: 'manifest.json',
759822
data: new TextEncoder().encode(JSON.stringify(manifest)),
760823
});
761-
const fileExists = await pathExists(outputPath);
762-
this.zipFile = await this.options.zipAdapter(
763-
fileExists ? outputPath : undefined,
764-
this.options.fileAdapter
765-
);
766-
const zipData = await this.zipFile.writeFiles(files);
767-
await writeBinaryToPath(outputPath, zipData);
824+
825+
if (outputPath.endsWith('.obz') || outputPath.endsWith('.zip')) {
826+
console.log('[OBF] Saving to ZIP file:', outputPath);
827+
const fileExists = await pathExists(outputPath);
828+
this.zipFile = await this.options.zipAdapter(
829+
fileExists ? outputPath : undefined,
830+
this.options.fileAdapter
831+
);
832+
const zipData = await this.zipFile.writeFiles(files);
833+
await writeBinaryToPath(outputPath, zipData);
834+
} else {
835+
console.log('[OBF] Saving to directory:', outputPath);
836+
if (!(await pathExists(outputPath))) await mkDir(outputPath);
837+
for (const file of files) {
838+
const filePath = join(outputPath, file.name);
839+
await writeBinaryToPath(filePath, file.data);
840+
}
841+
}
768842
}
769843
}
770844

@@ -796,16 +870,14 @@ class ObfProcessor extends BaseProcessor {
796870
const originalZip = new AdmZip(originalPath);
797871
const outputZip = new AdmZip();
798872

799-
const getPageFilename = (id: string): string => (id.endsWith('.obf') ? id : `${id}.obf`);
800-
801873
// Track which .obf files we're modifying
802874
const modifiedObfFiles = new Set<string>();
803875

804876
// Generate new .obf files for pages in the tree
805877
const newObfFiles = new Map<string, string>();
806878

807879
for (const page of Object.values(tree.pages)) {
808-
const obfFilename = getPageFilename(page.id);
880+
const obfFilename = this.getPageFilename(page.id, tree.metadata);
809881
modifiedObfFiles.add(obfFilename);
810882

811883
const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
@@ -822,7 +894,10 @@ class ObfProcessor extends BaseProcessor {
822894
root: tree.metadata.defaultHomePageId,
823895
paths: {
824896
boards: Object.fromEntries(
825-
Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])
897+
Object.entries(tree.pages).map(([id, page]) => [
898+
id,
899+
this.getPageFilename(page.id, tree.metadata),
900+
])
826901
),
827902
images: {},
828903
sounds: {},

0 commit comments

Comments
 (0)