@willwade/aac-processors 0.2.8 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -80,7 +80,7 @@ class ObfProcessor extends BaseProcessor {
80
80
  // Images are typically stored in an 'images' folder or root
81
81
  const possiblePaths = [
82
82
  imageData.path, // Explicit path if provided
83
- `images/${imageData.filename || imageId}`, // Standard images folder
83
+ `images/${imageData.path || imageId}`, // Standard images folder
84
84
  imageData.id, // Just the ID
85
85
  ].filter(Boolean);
86
86
  for (const imagePath of possiblePaths) {
@@ -126,14 +126,18 @@ class ObfProcessor extends BaseProcessor {
126
126
  return 'image/png';
127
127
  }
128
128
  }
129
- async processBoard(boardData, _boardPath, isZipEntry) {
129
+ getPageFilename(id, metadata) {
130
+ if (metadata._obfPagePaths && id in metadata._obfPagePaths)
131
+ return metadata._obfPagePaths[id];
132
+ if (id.endsWith('.obf'))
133
+ return id;
134
+ return `${id}.obf`;
135
+ }
136
+ async processBoard(boardData, _boardPath) {
130
137
  const sourceButtons = boardData.buttons || [];
131
138
  // Calculate page ID first (used to make button IDs unique)
132
- const pageId = isZipEntry
133
- ? _boardPath // Zip entry - use filename to match navigation paths
134
- : boardData?.id
135
- ? String(boardData.id)
136
- : _boardPath?.split(/[/\\]/).pop() || '';
139
+ const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || '';
140
+ const images = boardData.images;
137
141
  const buttons = await Promise.all(sourceButtons.map(async (btn) => {
138
142
  const semanticAction = btn.load_board
139
143
  ? {
@@ -157,11 +161,16 @@ class ObfProcessor extends BaseProcessor {
157
161
  // Resolve image if image_id is present
158
162
  let resolvedImage;
159
163
  let imageBuffer;
160
- if (btn.image_id && boardData.images) {
161
- resolvedImage =
162
- (await this.extractImageAsDataUrl(btn.image_id, boardData.images)) || undefined;
163
- imageBuffer =
164
- (await this.extractImageAsBuffer(btn.image_id, boardData.images)) || undefined;
164
+ if (btn.image_id && images) {
165
+ resolvedImage = (await this.extractImageAsDataUrl(btn.image_id, images)) || undefined;
166
+ imageBuffer = (await this.extractImageAsBuffer(btn.image_id, images)) || undefined;
167
+ // save image data
168
+ if (images) {
169
+ const imageIndex = images?.findIndex((img) => img.id === btn.image_id);
170
+ if (imageIndex !== -1) {
171
+ images[imageIndex].data = resolvedImage;
172
+ }
173
+ }
165
174
  }
166
175
  // Build parameters object for Grid3 export compatibility
167
176
  const buttonParameters = {};
@@ -198,7 +207,7 @@ class ObfProcessor extends BaseProcessor {
198
207
  parentId: null,
199
208
  locale: boardData.locale,
200
209
  descriptionHtml: boardData.description_html,
201
- images: boardData.images,
210
+ images,
202
211
  sounds: boardData.sounds,
203
212
  });
204
213
  // Process grid layout if available
@@ -288,7 +297,7 @@ class ObfProcessor extends BaseProcessor {
288
297
  return texts;
289
298
  }
290
299
  async loadIntoTree(filePathOrBuffer) {
291
- const { readBinaryFromInput, readTextFromInput } = this.options.fileAdapter;
300
+ const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory } = this.options.fileAdapter;
292
301
  // Detailed logging for debugging input
293
302
  const bufferLength = typeof filePathOrBuffer === 'string'
294
303
  ? null
@@ -330,7 +339,7 @@ class ObfProcessor extends BaseProcessor {
330
339
  const boardData = await tryParseObfJson(content);
331
340
  if (boardData) {
332
341
  console.log('[OBF] Detected .obf file, parsed as JSON');
333
- const page = await this.processBoard(boardData, filePathOrBuffer, false);
342
+ const page = await this.processBoard(boardData, filePathOrBuffer);
334
343
  tree.addPage(page);
335
344
  // Set metadata from root board
336
345
  tree.metadata.format = 'obf';
@@ -354,22 +363,30 @@ class ObfProcessor extends BaseProcessor {
354
363
  throw err;
355
364
  }
356
365
  }
357
- // Detect likely zip signature first
358
- async function isLikelyZip(input) {
359
- if (typeof input === 'string') {
360
- const lowered = input.toLowerCase();
361
- return lowered.endsWith('.zip') || lowered.endsWith('.obz');
366
+ // Determine if input is ZIP, directory, or OBF JSON string/buffer
367
+ let fileType = 'obf';
368
+ if (typeof filePathOrBuffer !== 'string') {
369
+ const bytes = await readBinaryFromInput(filePathOrBuffer);
370
+ if (bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b)
371
+ fileType = 'zip';
372
+ }
373
+ else {
374
+ if (await isDirectory(filePathOrBuffer)) {
375
+ fileType = 'dir';
376
+ }
377
+ else {
378
+ const lowered = filePathOrBuffer.toLowerCase();
379
+ if (lowered.endsWith('.zip') || lowered.endsWith('.obz'))
380
+ fileType = 'zip';
362
381
  }
363
- const bytes = await readBinaryFromInput(input);
364
- return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
365
382
  }
366
383
  // Check if input is a buffer or string that parses as OBF JSON; throw if neither JSON nor ZIP
367
- if (!(await isLikelyZip(filePathOrBuffer))) {
384
+ if (fileType === 'obf') {
368
385
  const asJson = await tryParseObfJson(filePathOrBuffer);
369
386
  if (!asJson)
370
387
  throw new Error('Invalid OBF content: not JSON and not ZIP');
371
388
  console.log('[OBF] Detected buffer/string as OBF JSON');
372
- const page = await this.processBoard(asJson, '[bufferOrString]', false);
389
+ const page = await this.processBoard(asJson, '[bufferOrString]');
373
390
  tree.addPage(page);
374
391
  // Set metadata from root board
375
392
  tree.metadata.format = 'obf';
@@ -385,18 +402,31 @@ class ObfProcessor extends BaseProcessor {
385
402
  tree.rootId = page.id;
386
403
  return tree;
387
404
  }
388
- try {
389
- this.zipFile = await this.options.zipAdapter(filePathOrBuffer);
390
- }
391
- catch (err) {
392
- console.error('[OBF] Error loading ZIP:', err);
393
- throw err;
405
+ this.zipFile = {
406
+ readFile: async (name) => {
407
+ return await readBinaryFromInput(join(filePathOrBuffer, name));
408
+ },
409
+ listFiles: () => {
410
+ throw new Error('Not implemented for directory input');
411
+ },
412
+ writeFiles: () => {
413
+ throw new Error('Not implemented for directory input');
414
+ },
415
+ };
416
+ if (fileType === 'zip') {
417
+ try {
418
+ this.zipFile = await this.options.zipAdapter(filePathOrBuffer);
419
+ }
420
+ catch (err) {
421
+ console.error('[OBF] Error loading ZIP:', err);
422
+ throw err;
423
+ }
394
424
  }
395
425
  // Store the ZIP file reference for image extraction
396
426
  this.imageCache.clear(); // Clear cache for new file
397
- console.log('[OBF] Detected zip archive, extracting .obf files');
427
+ console.log('[OBF] Detected zip archive or directory, extracting .obf files');
398
428
  // List manifest and OBF files
399
- const filesInZip = this.zipFile.listFiles();
429
+ const filesInZip = fileType === 'zip' ? this.zipFile.listFiles() : await listDir(filePathOrBuffer);
400
430
  const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json');
401
431
  let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf'));
402
432
  // Attempt to read manifest
@@ -430,7 +460,7 @@ class ObfProcessor extends BaseProcessor {
430
460
  const content = await this.zipFile.readFile(entryName);
431
461
  const boardData = await tryParseObfJson(decodeText(content));
432
462
  if (boardData) {
433
- const page = await this.processBoard(boardData, entryName, true);
463
+ const page = await this.processBoard(boardData, entryName);
434
464
  tree.addPage(page);
435
465
  // Set metadata if not already set (use first board as reference)
436
466
  if (!tree.metadata.format) {
@@ -439,12 +469,16 @@ class ObfProcessor extends BaseProcessor {
439
469
  tree.metadata.description = boardData.description_html;
440
470
  tree.metadata.locale = boardData.locale;
441
471
  tree.metadata.id = boardData.id;
472
+ tree.metadata._obfPagePaths = { [page.id]: entryName };
442
473
  if (boardData.url)
443
474
  tree.metadata.url = boardData.url;
444
475
  if (boardData.locale)
445
476
  tree.metadata.languages = [boardData.locale];
446
477
  tree.rootId = page.id;
447
478
  }
479
+ else {
480
+ tree.metadata._obfPagePaths[page.id] = entryName;
481
+ }
448
482
  }
449
483
  else {
450
484
  console.warn('[OBF] Skipped entry (not valid OBF JSON):', entryName);
@@ -497,11 +531,18 @@ class ObfProcessor extends BaseProcessor {
497
531
  }
498
532
  return { rows: totalRows, columns: totalColumns, order, buttonPositions };
499
533
  }
500
- createObfBoardFromPage(page, fallbackName, metadata) {
534
+ createObfBoardFromPage(page, fallbackName, metadata, embedData = false) {
501
535
  const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page);
502
536
  const boardName = metadata?.name && page.id === metadata?.defaultHomePageId
503
537
  ? metadata.name
504
538
  : page.name || fallbackName;
539
+ let images = Array.isArray(page.images) ? page.images : [];
540
+ if (!embedData) {
541
+ images = images.map((image) => {
542
+ delete image.data;
543
+ return image;
544
+ });
545
+ }
505
546
  return {
506
547
  format: OBF_FORMAT_VERSION,
507
548
  id: page.id,
@@ -538,7 +579,7 @@ class ObfProcessor extends BaseProcessor {
538
579
  hidden: button.visibility === 'Hidden' || false,
539
580
  };
540
581
  }),
541
- images: Array.isArray(page.images) ? page.images : [],
582
+ images,
542
583
  sounds: Array.isArray(page.sounds) ? page.sounds : [],
543
584
  };
544
585
  }
@@ -575,23 +616,22 @@ class ObfProcessor extends BaseProcessor {
575
616
  await this.saveFromTree(tree, outputPath);
576
617
  return await readBinaryFromInput(outputPath);
577
618
  }
578
- async saveFromTree(tree, outputPath) {
579
- const { writeTextToPath, writeBinaryToPath, pathExists } = this.options.fileAdapter;
619
+ async saveFromTree(tree, outputPath, embedData = false) {
620
+ const { writeTextToPath, writeBinaryToPath, pathExists, mkDir, join } = this.options.fileAdapter;
580
621
  if (outputPath.endsWith('.obf')) {
581
622
  // Save as single OBF JSON file
582
623
  const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0];
583
624
  if (!rootPage) {
584
625
  throw new Error('No pages to save');
585
626
  }
586
- const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata);
627
+ const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata, embedData);
587
628
  await writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2));
588
629
  }
589
630
  else {
590
- const getPageFilename = (id) => (id.endsWith('.obf') ? id : `${id}.obf`);
591
631
  const files = Object.values(tree.pages).map((page) => {
592
- const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
632
+ const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata, embedData);
593
633
  const obfContent = JSON.stringify(obfBoard, null, 2);
594
- const name = getPageFilename(page.id);
634
+ const name = this.getPageFilename(page.id, tree.metadata);
595
635
  return {
596
636
  name,
597
637
  data: new TextEncoder().encode(obfContent),
@@ -601,7 +641,10 @@ class ObfProcessor extends BaseProcessor {
601
641
  format: OBF_FORMAT_VERSION,
602
642
  root: tree.metadata.defaultHomePageId,
603
643
  paths: {
604
- boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])),
644
+ boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [
645
+ id,
646
+ this.getPageFilename(page.id, tree.metadata),
647
+ ])),
605
648
  images: {}, //TODO Add support for saving images as files
606
649
  sounds: {}, //TODO Add support for saving sounds as files
607
650
  },
@@ -610,10 +653,22 @@ class ObfProcessor extends BaseProcessor {
610
653
  name: 'manifest.json',
611
654
  data: new TextEncoder().encode(JSON.stringify(manifest)),
612
655
  });
613
- const fileExists = await pathExists(outputPath);
614
- this.zipFile = await this.options.zipAdapter(fileExists ? outputPath : undefined, this.options.fileAdapter);
615
- const zipData = await this.zipFile.writeFiles(files);
616
- await writeBinaryToPath(outputPath, zipData);
656
+ if (outputPath.endsWith('.obz') || outputPath.endsWith('.zip')) {
657
+ console.log('[OBF] Saving to ZIP file:', outputPath);
658
+ const fileExists = await pathExists(outputPath);
659
+ this.zipFile = await this.options.zipAdapter(fileExists ? outputPath : undefined, this.options.fileAdapter);
660
+ const zipData = await this.zipFile.writeFiles(files);
661
+ await writeBinaryToPath(outputPath, zipData);
662
+ }
663
+ else {
664
+ console.log('[OBF] Saving to directory:', outputPath);
665
+ if (!(await pathExists(outputPath)))
666
+ await mkDir(outputPath);
667
+ for (const file of files) {
668
+ const filePath = join(outputPath, file.name);
669
+ await writeBinaryToPath(filePath, file.data);
670
+ }
671
+ }
617
672
  }
618
673
  }
619
674
  /**
@@ -640,13 +695,12 @@ class ObfProcessor extends BaseProcessor {
640
695
  const AdmZip = (await import('adm-zip')).default;
641
696
  const originalZip = new AdmZip(originalPath);
642
697
  const outputZip = new AdmZip();
643
- const getPageFilename = (id) => (id.endsWith('.obf') ? id : `${id}.obf`);
644
698
  // Track which .obf files we're modifying
645
699
  const modifiedObfFiles = new Set();
646
700
  // Generate new .obf files for pages in the tree
647
701
  const newObfFiles = new Map();
648
702
  for (const page of Object.values(tree.pages)) {
649
- const obfFilename = getPageFilename(page.id);
703
+ const obfFilename = this.getPageFilename(page.id, tree.metadata);
650
704
  modifiedObfFiles.add(obfFilename);
651
705
  const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
652
706
  const obfContent = JSON.stringify(obfBoard, null, 2);
@@ -659,7 +713,10 @@ class ObfProcessor extends BaseProcessor {
659
713
  format: OBF_FORMAT_VERSION,
660
714
  root: tree.metadata.defaultHomePageId,
661
715
  paths: {
662
- boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])),
716
+ boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [
717
+ id,
718
+ this.getPageFilename(page.id, tree.metadata),
719
+ ])),
663
720
  images: {},
664
721
  sounds: {},
665
722
  },
@@ -1,3 +1,2 @@
1
1
  export { MorphologyEngine } from './engine';
2
- export { Grid3VerbsParser } from './grid3VerbsParser';
3
2
  export { WordFormGenerator } from './wordFormGenerator';
@@ -54,12 +54,17 @@ declare class GridsetProcessor extends BaseProcessor {
54
54
  /**
55
55
  * Save a modified tree while preserving all original files (settings, images, assets)
56
56
  * This method only updates the grid.xml files for pages in the tree, keeping everything else intact.
57
+ * It preserves the original grid structure and only updates button labels and messages.
57
58
  *
58
59
  * @param originalPath - Path to the original gridset file
59
60
  * @param tree - Modified AACTree with pages to save
60
61
  * @param outputPath - Path where the modified gridset should be saved
61
62
  */
62
63
  saveModifiedTree(originalPath: string, tree: AACTree, outputPath: string): Promise<void>;
64
+ /**
65
+ * Create a basic grid XML for a page when original doesn't exist
66
+ */
67
+ private createBasicGridXml;
63
68
  private findButtonPosition;
64
69
  /**
65
70
  * Extract strings with metadata for aac-tools-platform compatibility