@@ -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+
7391interface 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