@willwade/aac-processors 0.2.9 → 0.2.11

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.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Grid3 XML Formatter
3
+ *
4
+ * Utilities for formatting XML to match Grid 3's specific requirements.
5
+ * Grid 3 has strict formatting requirements including line endings, self-closing
6
+ * tag spacing, and specific tag expansion rules.
7
+ */
8
+ /**
9
+ * Format XML string to match Grid 3's requirements
10
+ *
11
+ * Grid 3 requires specific formatting:
12
+ * - Windows line endings (\r\n)
13
+ * - Space before /> in self-closing tags: <Element /> not <Element/>
14
+ * - Plain apostrophes instead of &apos;
15
+ * - Specific tags expanded to full opening/closing format
16
+ * - CDATA for empty/whitespace captions and <r> tags
17
+ *
18
+ * @param xml - The XML string to format
19
+ * @returns Formatted XML string compatible with Grid 3
20
+ *
21
+ * @example
22
+ * const formatted = formatGrid3Xml('<Grid><Cell X="0"/></Grid>');
23
+ * // Returns: '<Grid>\r\n<Cell X="0" />\r\n</Grid>'
24
+ */
25
+ export declare function formatGrid3Xml(xml: string): string;
26
+ /**
27
+ * Format empty/whitespace captions with CDATA for Grid 3 compatibility
28
+ *
29
+ * Grid 3 requires <![CDATA[ ]]> for empty captions, not plain text.
30
+ * Also handles <r> tags which need CDATA for spaces to prevent stripping.
31
+ *
32
+ * @param xml - The XML string to format
33
+ * @returns XML string with CDATA-wrapped empty content
34
+ */
35
+ export declare function formatEmptyCaptionsWithCdata(xml: string): string;
36
+ /**
37
+ * Complete XML formatting for Grid 3 compatibility
38
+ * Combines all Grid 3 XML formatting requirements
39
+ *
40
+ * @param xml - The XML string to format
41
+ * @returns Fully formatted XML string compatible with Grid 3
42
+ */
43
+ export declare function formatGrid3XmlComplete(xml: string): string;
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ /**
3
+ * Grid3 XML Formatter
4
+ *
5
+ * Utilities for formatting XML to match Grid 3's specific requirements.
6
+ * Grid 3 has strict formatting requirements including line endings, self-closing
7
+ * tag spacing, and specific tag expansion rules.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.formatGrid3Xml = formatGrid3Xml;
11
+ exports.formatEmptyCaptionsWithCdata = formatEmptyCaptionsWithCdata;
12
+ exports.formatGrid3XmlComplete = formatGrid3XmlComplete;
13
+ /**
14
+ * Tags that Grid 3 requires in full opening/closing format instead of self-closing
15
+ * Grid 3 cannot parse <AudioDescription /> - it requires <AudioDescription></AudioDescription>
16
+ */
17
+ const TAGS_NEEDING_EXPANSION = ['AudioDescription', 'VideoDescription'];
18
+ /**
19
+ * Format XML string to match Grid 3's requirements
20
+ *
21
+ * Grid 3 requires specific formatting:
22
+ * - Windows line endings (\r\n)
23
+ * - Space before /> in self-closing tags: <Element /> not <Element/>
24
+ * - Plain apostrophes instead of &apos;
25
+ * - Specific tags expanded to full opening/closing format
26
+ * - CDATA for empty/whitespace captions and <r> tags
27
+ *
28
+ * @param xml - The XML string to format
29
+ * @returns Formatted XML string compatible with Grid 3
30
+ *
31
+ * @example
32
+ * const formatted = formatGrid3Xml('<Grid><Cell X="0"/></Grid>');
33
+ * // Returns: '<Grid>\r\n<Cell X="0" />\r\n</Grid>'
34
+ */
35
+ function formatGrid3Xml(xml) {
36
+ let formatted = xml;
37
+ // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility
38
+ formatted = formatted.replace(/\n/g, '\r\n');
39
+ // Add space before /> in self-closing tags to match Grid 3's expected format
40
+ // Grid 3 original files use <Element /> not <Element/>
41
+ formatted = formatted.replace(/<(\w+)([^>]*)\/>/g, '<$1$2 />');
42
+ // Decode XML entities back to plain text to match Grid 3's expected format
43
+ // Grid 3 expects plain apostrophes, not &apos;
44
+ formatted = formatted.replace(/&apos;/g, "'");
45
+ formatted = formatted.replace(/&quot;/g, '"');
46
+ formatted = formatted.replace(/&lt;/g, '<');
47
+ formatted = formatted.replace(/&gt;/g, '>');
48
+ // Expand only specific self-closing tags that Grid 3 requires in full opening/closing format
49
+ // This must be done AFTER adding spaces, so we need to match the format with spaces
50
+ for (const tag of TAGS_NEEDING_EXPANSION) {
51
+ formatted = formatted.replace(new RegExp(`<${tag}(\\s+[^>]*)? />`, 'g'), `<${tag}$1></${tag}>`);
52
+ }
53
+ return formatted;
54
+ }
55
+ /**
56
+ * Format empty/whitespace captions with CDATA for Grid 3 compatibility
57
+ *
58
+ * Grid 3 requires <![CDATA[ ]]> for empty captions, not plain text.
59
+ * Also handles <r> tags which need CDATA for spaces to prevent stripping.
60
+ *
61
+ * @param xml - The XML string to format
62
+ * @returns XML string with CDATA-wrapped empty content
63
+ */
64
+ function formatEmptyCaptionsWithCdata(xml) {
65
+ let formatted = xml;
66
+ // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility
67
+ // Grid 3 requires <![CDATA[ ]]> for empty captions, not plain text
68
+ formatted = formatted.replace(/<Caption><\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
69
+ formatted = formatted.replace(/<Caption> <\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
70
+ formatted = formatted.replace(/<Caption> {2}<\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
71
+ // Preserve CDATA in <r> tags for text parameters
72
+ // Spaces in <r> tags must use CDATA or they get stripped during rendering
73
+ // e.g., <r> </r> becomes <r><![CDATA[ ]]></r>
74
+ formatted = formatted.replace(/<r> <\/r>/g, '<r><![CDATA[ ]]></r>');
75
+ formatted = formatted.replace(/<r> {2}<\/r>/g, '<r><![CDATA[ ]]></r>');
76
+ return formatted;
77
+ }
78
+ /**
79
+ * Complete XML formatting for Grid 3 compatibility
80
+ * Combines all Grid 3 XML formatting requirements
81
+ *
82
+ * @param xml - The XML string to format
83
+ * @returns Fully formatted XML string compatible with Grid 3
84
+ */
85
+ function formatGrid3XmlComplete(xml) {
86
+ let formatted = formatGrid3Xml(xml);
87
+ formatted = formatEmptyCaptionsWithCdata(formatted);
88
+ return formatted;
89
+ }
@@ -31,6 +31,9 @@ const resolver_1 = require("./gridset/resolver");
31
31
  const translationProcessor_1 = require("../utilities/translation/translationProcessor");
32
32
  const password_1 = require("./gridset/password");
33
33
  const crypto_1 = require("./gridset/crypto");
34
+ const xmlFormatter_1 = require("./gridset/xmlFormatter");
35
+ const gridCalculations_1 = require("./gridset/gridCalculations");
36
+ const cellHelpers_1 = require("./gridset/cellHelpers");
34
37
  const gridsetValidator_1 = require("../validation/gridsetValidator");
35
38
  // New imports for enhanced Grid 3 support
36
39
  const pluginTypes_1 = require("./gridset/pluginTypes");
@@ -2221,33 +2224,11 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2221
2224
  }
2222
2225
  // Helper method to calculate column definitions based on page layout
2223
2226
  calculateColumnDefinitions(page) {
2224
- let maxCols = 4; // Default minimum
2225
- if (page.grid && page.grid.length > 0) {
2226
- maxCols = Math.max(maxCols, page.grid[0]?.length || 0);
2227
- }
2228
- else {
2229
- // Fallback: estimate from button count
2230
- maxCols = Math.max(4, Math.ceil(Math.sqrt(page.buttons.length)));
2231
- }
2232
- return {
2233
- ColumnDefinition: Array(maxCols).fill({}),
2234
- };
2227
+ return (0, gridCalculations_1.calculateColumnDefinitions)(page);
2235
2228
  }
2236
2229
  // Helper method to calculate row definitions based on page layout
2237
2230
  calculateRowDefinitions(page, addWorkspaceOffset = false) {
2238
- let maxRows = 4; // Default minimum
2239
- const offset = addWorkspaceOffset ? 1 : 0;
2240
- if (page.grid && page.grid.length > 0) {
2241
- maxRows = Math.max(maxRows, page.grid.length + offset);
2242
- }
2243
- else {
2244
- // Fallback: estimate from button count
2245
- const estimatedCols = Math.ceil(Math.sqrt(page.buttons.length));
2246
- maxRows = Math.max(4, Math.ceil(page.buttons.length / estimatedCols)) + offset;
2247
- }
2248
- return {
2249
- RowDefinition: Array(maxRows).fill({}),
2250
- };
2231
+ return (0, gridCalculations_1.calculateRowDefinitions)(page, addWorkspaceOffset);
2251
2232
  }
2252
2233
  /**
2253
2234
  * Save a modified tree while preserving all original files (settings, images, assets)
@@ -2393,6 +2374,9 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2393
2374
  }
2394
2375
  }
2395
2376
  }
2377
+ // DO NOT create new cells - the system should only modify existing content
2378
+ // Personalized vocabulary is added to WordList cells via the WordList.Items array
2379
+ // Creating new cells would corrupt the grid structure
2396
2380
  // Update the page's WordList with new words from modified buttons
2397
2381
  // Collect all modified buttons that should be added to the WordList
2398
2382
  const newWordListItems = [];
@@ -2405,9 +2389,18 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2405
2389
  ? [originalGrid.Grid.Cells.Cell]
2406
2390
  : [];
2407
2391
  const cell = cellArray.find((c) => {
2408
- const cellX = parseInt(String(c['@_X'] || '0'), 10);
2409
- const cellY = parseInt(String(c['@_Y'] || '0'), 10);
2410
- return cellX === pos.x && cellY === pos.y;
2392
+ const cellY = parseInt(String(c['@_Y'] || c['@_Row'] || '0'), 10);
2393
+ // Check Y position first
2394
+ if (cellY !== pos.y) {
2395
+ return false;
2396
+ }
2397
+ const cellX = c['@_X'] !== undefined ? parseInt(String(c['@_X']), 10) : undefined;
2398
+ // If cell has no X attribute (full-width cell), it matches any button at this Y
2399
+ if (cellX === undefined) {
2400
+ return true;
2401
+ }
2402
+ // Otherwise, check exact X match
2403
+ return cellX === pos.x;
2411
2404
  });
2412
2405
  if (cell) {
2413
2406
  const contentType = cell.Content?.ContentType || cell.Content?.contentType;
@@ -2416,11 +2409,14 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2416
2409
  // Note: Prediction cells are already skipped earlier, so they won't reach here
2417
2410
  if (isWordListCell) {
2418
2411
  // Add this button to the WordList with proper Grid 3 format
2419
- // Format: <Text><s><r>label</r></s></Text>
2412
+ // Format: <Text><p><s><r>label</r></s></p></Text>
2413
+ // Note: <p> wrapper is required by Grid 3's WordList format
2420
2414
  newWordListItems.push({
2421
2415
  Text: {
2422
- s: {
2423
- r: button.label,
2416
+ p: {
2417
+ s: {
2418
+ r: button.label,
2419
+ },
2424
2420
  },
2425
2421
  },
2426
2422
  Image: '', // No image for user-added words
@@ -2447,23 +2443,9 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2447
2443
  originalGrid.Grid.WordList.Items.WordListItem = allItems;
2448
2444
  }
2449
2445
  }
2450
- // Build the updated grid XML and convert to Windows line endings
2446
+ // Build the updated grid XML and format for Grid 3 compatibility
2451
2447
  let builtXml = gridBuilder.build(originalGrid);
2452
- // Convert Unix line endings to Windows (\r\n) for Grid 3 compatibility
2453
- builtXml = builtXml.replace(/\n/g, '\r\n');
2454
- // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility
2455
- // Grid 3 cannot parse <AudioDescription /> - it requires <AudioDescription></AudioDescription>
2456
- builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2></$1>');
2457
- // Convert empty/whitespace captions to CDATA format for Grid 3 compatibility
2458
- // Grid 3 requires <![CDATA[ ]]> for empty captions, not plain text
2459
- builtXml = builtXml.replace(/<Caption><\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2460
- builtXml = builtXml.replace(/<Caption> <\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2461
- builtXml = builtXml.replace(/<Caption> {2}<\/Caption>/g, '<Caption><![CDATA[ ]]></Caption>');
2462
- // Preserve CDATA in <r> tags for text parameters
2463
- // Spaces in <r> tags must use CDATA or they get stripped during rendering
2464
- // e.g., <r> </r> becomes <r><![CDATA[ ]]></r>
2465
- builtXml = builtXml.replace(/<r> <\/r>/g, '<r><![CDATA[ ]]></r>');
2466
- builtXml = builtXml.replace(/<r> {2}<\/r>/g, '<r><![CDATA[ ]]></r>');
2448
+ builtXml = (0, xmlFormatter_1.formatGrid3XmlComplete)(builtXml);
2467
2449
  newGridFiles.set(gridPath, builtXml);
2468
2450
  }
2469
2451
  // Copy all files from original zip, replacing modified grid files
@@ -2532,57 +2514,14 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
2532
2514
  // Preserve Grid 3 XML formatting requirements
2533
2515
  suppressBooleanAttributes: false,
2534
2516
  });
2535
- // Build the grid XML and convert to Windows line endings for Grid 3 compatibility
2517
+ // Build the grid XML and format for Grid 3 compatibility
2536
2518
  let builtXml = gridBuilder.build(gridData);
2537
- builtXml = builtXml.replace(/\n/g, '\r\n');
2538
- // Expand self-closing tags to full opening/closing tags for Grid 3 compatibility
2539
- builtXml = builtXml.replace(/<(\w+)(\s+[^>]*)?\s*\/>/g, '<$1$2></$1>');
2519
+ builtXml = (0, xmlFormatter_1.formatGrid3XmlComplete)(builtXml);
2540
2520
  return builtXml;
2541
2521
  }
2542
2522
  // Helper method to find button position with span information
2543
2523
  findButtonPosition(page, button, fallbackIndex) {
2544
- if (page.grid && page.grid.length > 0) {
2545
- // Search for button in grid layout and calculate span
2546
- for (let y = 0; y < page.grid.length; y++) {
2547
- for (let x = 0; x < page.grid[y].length; x++) {
2548
- const current = page.grid[y][x];
2549
- if (current && current.id === button.id) {
2550
- // Calculate span by checking how far the same button extends
2551
- let columnSpan = 1;
2552
- let rowSpan = 1;
2553
- // Check column span (rightward)
2554
- while (x + columnSpan < page.grid[y].length) {
2555
- const right = page.grid[y][x + columnSpan];
2556
- if (right && right.id === button.id) {
2557
- columnSpan++;
2558
- }
2559
- else {
2560
- break;
2561
- }
2562
- }
2563
- // Check row span (downward)
2564
- while (y + rowSpan < page.grid.length) {
2565
- const below = page.grid[y + rowSpan][x];
2566
- if (below && below.id === button.id) {
2567
- rowSpan++;
2568
- }
2569
- else {
2570
- break;
2571
- }
2572
- }
2573
- return { x, y, columnSpan, rowSpan };
2574
- }
2575
- }
2576
- }
2577
- }
2578
- // Fallback positioning
2579
- const gridCols = page.grid?.[0]?.length || Math.ceil(Math.sqrt(page.buttons.length));
2580
- return {
2581
- x: fallbackIndex % gridCols,
2582
- y: Math.floor(fallbackIndex / gridCols),
2583
- columnSpan: 1,
2584
- rowSpan: 1,
2585
- };
2524
+ return (0, cellHelpers_1.findButtonPosition)(page, button, fallbackIndex);
2586
2525
  }
2587
2526
  /**
2588
2527
  * Extract strings with metadata for aac-tools-platform compatibility
@@ -16,13 +16,14 @@ declare class ObfProcessor extends BaseProcessor {
16
16
  */
17
17
  private extractImageAsDataUrl;
18
18
  private getMimeTypeFromFilename;
19
+ private getPageFilename;
19
20
  private processBoard;
20
21
  extractTexts(filePathOrBuffer: ProcessorInput): Promise<string[]>;
21
22
  loadIntoTree(filePathOrBuffer: ProcessorInput): Promise<AACTree>;
22
23
  private buildGridMetadata;
23
24
  private createObfBoardFromPage;
24
25
  processTexts(filePathOrBuffer: ProcessorInput, translations: Map<string, string>, outputPath: string): Promise<Uint8Array>;
25
- saveFromTree(tree: AACTree, outputPath: string): Promise<void>;
26
+ saveFromTree(tree: AACTree, outputPath: string, embedData?: boolean): Promise<void>;
26
27
  /**
27
28
  * Save a modified tree while preserving all original files (images, sounds, assets)
28
29
  * This method only updates the .obf files for pages in the tree, keeping everything else intact.
@@ -106,7 +106,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
106
106
  // Images are typically stored in an 'images' folder or root
107
107
  const possiblePaths = [
108
108
  imageData.path, // Explicit path if provided
109
- `images/${imageData.filename || imageId}`, // Standard images folder
109
+ `images/${imageData.path || imageId}`, // Standard images folder
110
110
  imageData.id, // Just the ID
111
111
  ].filter(Boolean);
112
112
  for (const imagePath of possiblePaths) {
@@ -152,14 +152,18 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
152
152
  return 'image/png';
153
153
  }
154
154
  }
155
- async processBoard(boardData, _boardPath, isZipEntry) {
155
+ getPageFilename(id, metadata) {
156
+ if (metadata._obfPagePaths && id in metadata._obfPagePaths)
157
+ return metadata._obfPagePaths[id];
158
+ if (id.endsWith('.obf'))
159
+ return id;
160
+ return `${id}.obf`;
161
+ }
162
+ async processBoard(boardData, _boardPath) {
156
163
  const sourceButtons = boardData.buttons || [];
157
164
  // Calculate page ID first (used to make button IDs unique)
158
- const pageId = isZipEntry
159
- ? _boardPath // Zip entry - use filename to match navigation paths
160
- : boardData?.id
161
- ? String(boardData.id)
162
- : _boardPath?.split(/[/\\]/).pop() || '';
165
+ const pageId = boardData?.id ? String(boardData.id) : _boardPath?.split(/[/\\]/).pop() || '';
166
+ const images = boardData.images;
163
167
  const buttons = await Promise.all(sourceButtons.map(async (btn) => {
164
168
  const semanticAction = btn.load_board
165
169
  ? {
@@ -183,11 +187,16 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
183
187
  // Resolve image if image_id is present
184
188
  let resolvedImage;
185
189
  let imageBuffer;
186
- if (btn.image_id && boardData.images) {
187
- resolvedImage =
188
- (await this.extractImageAsDataUrl(btn.image_id, boardData.images)) || undefined;
189
- imageBuffer =
190
- (await this.extractImageAsBuffer(btn.image_id, boardData.images)) || undefined;
190
+ if (btn.image_id && images) {
191
+ resolvedImage = (await this.extractImageAsDataUrl(btn.image_id, images)) || undefined;
192
+ imageBuffer = (await this.extractImageAsBuffer(btn.image_id, images)) || undefined;
193
+ // save image data
194
+ if (images) {
195
+ const imageIndex = images?.findIndex((img) => img.id === btn.image_id);
196
+ if (imageIndex !== -1) {
197
+ images[imageIndex].data = resolvedImage;
198
+ }
199
+ }
191
200
  }
192
201
  // Build parameters object for Grid3 export compatibility
193
202
  const buttonParameters = {};
@@ -224,7 +233,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
224
233
  parentId: null,
225
234
  locale: boardData.locale,
226
235
  descriptionHtml: boardData.description_html,
227
- images: boardData.images,
236
+ images,
228
237
  sounds: boardData.sounds,
229
238
  });
230
239
  // Process grid layout if available
@@ -314,7 +323,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
314
323
  return texts;
315
324
  }
316
325
  async loadIntoTree(filePathOrBuffer) {
317
- const { readBinaryFromInput, readTextFromInput } = this.options.fileAdapter;
326
+ const { readBinaryFromInput, readTextFromInput, listDir, join, isDirectory } = this.options.fileAdapter;
318
327
  // Detailed logging for debugging input
319
328
  const bufferLength = typeof filePathOrBuffer === 'string'
320
329
  ? null
@@ -356,7 +365,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
356
365
  const boardData = await tryParseObfJson(content);
357
366
  if (boardData) {
358
367
  console.log('[OBF] Detected .obf file, parsed as JSON');
359
- const page = await this.processBoard(boardData, filePathOrBuffer, false);
368
+ const page = await this.processBoard(boardData, filePathOrBuffer);
360
369
  tree.addPage(page);
361
370
  // Set metadata from root board
362
371
  tree.metadata.format = 'obf';
@@ -380,22 +389,30 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
380
389
  throw err;
381
390
  }
382
391
  }
383
- // Detect likely zip signature first
384
- async function isLikelyZip(input) {
385
- if (typeof input === 'string') {
386
- const lowered = input.toLowerCase();
387
- return lowered.endsWith('.zip') || lowered.endsWith('.obz');
392
+ // Determine if input is ZIP, directory, or OBF JSON string/buffer
393
+ let fileType = 'obf';
394
+ if (typeof filePathOrBuffer !== 'string') {
395
+ const bytes = await readBinaryFromInput(filePathOrBuffer);
396
+ if (bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b)
397
+ fileType = 'zip';
398
+ }
399
+ else {
400
+ if (await isDirectory(filePathOrBuffer)) {
401
+ fileType = 'dir';
402
+ }
403
+ else {
404
+ const lowered = filePathOrBuffer.toLowerCase();
405
+ if (lowered.endsWith('.zip') || lowered.endsWith('.obz'))
406
+ fileType = 'zip';
388
407
  }
389
- const bytes = await readBinaryFromInput(input);
390
- return bytes.length >= 2 && bytes[0] === 0x50 && bytes[1] === 0x4b;
391
408
  }
392
409
  // Check if input is a buffer or string that parses as OBF JSON; throw if neither JSON nor ZIP
393
- if (!(await isLikelyZip(filePathOrBuffer))) {
410
+ if (fileType === 'obf') {
394
411
  const asJson = await tryParseObfJson(filePathOrBuffer);
395
412
  if (!asJson)
396
413
  throw new Error('Invalid OBF content: not JSON and not ZIP');
397
414
  console.log('[OBF] Detected buffer/string as OBF JSON');
398
- const page = await this.processBoard(asJson, '[bufferOrString]', false);
415
+ const page = await this.processBoard(asJson, '[bufferOrString]');
399
416
  tree.addPage(page);
400
417
  // Set metadata from root board
401
418
  tree.metadata.format = 'obf';
@@ -411,18 +428,31 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
411
428
  tree.rootId = page.id;
412
429
  return tree;
413
430
  }
414
- try {
415
- this.zipFile = await this.options.zipAdapter(filePathOrBuffer);
416
- }
417
- catch (err) {
418
- console.error('[OBF] Error loading ZIP:', err);
419
- throw err;
431
+ this.zipFile = {
432
+ readFile: async (name) => {
433
+ return await readBinaryFromInput(join(filePathOrBuffer, name));
434
+ },
435
+ listFiles: () => {
436
+ throw new Error('Not implemented for directory input');
437
+ },
438
+ writeFiles: () => {
439
+ throw new Error('Not implemented for directory input');
440
+ },
441
+ };
442
+ if (fileType === 'zip') {
443
+ try {
444
+ this.zipFile = await this.options.zipAdapter(filePathOrBuffer);
445
+ }
446
+ catch (err) {
447
+ console.error('[OBF] Error loading ZIP:', err);
448
+ throw err;
449
+ }
420
450
  }
421
451
  // Store the ZIP file reference for image extraction
422
452
  this.imageCache.clear(); // Clear cache for new file
423
- console.log('[OBF] Detected zip archive, extracting .obf files');
453
+ console.log('[OBF] Detected zip archive or directory, extracting .obf files');
424
454
  // List manifest and OBF files
425
- const filesInZip = this.zipFile.listFiles();
455
+ const filesInZip = fileType === 'zip' ? this.zipFile.listFiles() : await listDir(filePathOrBuffer);
426
456
  const manifestFile = filesInZip.filter((name) => name.toLowerCase() === 'manifest.json');
427
457
  let obfEntries = filesInZip.filter((name) => name.toLowerCase().endsWith('.obf'));
428
458
  // Attempt to read manifest
@@ -456,7 +486,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
456
486
  const content = await this.zipFile.readFile(entryName);
457
487
  const boardData = await tryParseObfJson((0, io_1.decodeText)(content));
458
488
  if (boardData) {
459
- const page = await this.processBoard(boardData, entryName, true);
489
+ const page = await this.processBoard(boardData, entryName);
460
490
  tree.addPage(page);
461
491
  // Set metadata if not already set (use first board as reference)
462
492
  if (!tree.metadata.format) {
@@ -465,12 +495,16 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
465
495
  tree.metadata.description = boardData.description_html;
466
496
  tree.metadata.locale = boardData.locale;
467
497
  tree.metadata.id = boardData.id;
498
+ tree.metadata._obfPagePaths = { [page.id]: entryName };
468
499
  if (boardData.url)
469
500
  tree.metadata.url = boardData.url;
470
501
  if (boardData.locale)
471
502
  tree.metadata.languages = [boardData.locale];
472
503
  tree.rootId = page.id;
473
504
  }
505
+ else {
506
+ tree.metadata._obfPagePaths[page.id] = entryName;
507
+ }
474
508
  }
475
509
  else {
476
510
  console.warn('[OBF] Skipped entry (not valid OBF JSON):', entryName);
@@ -523,11 +557,18 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
523
557
  }
524
558
  return { rows: totalRows, columns: totalColumns, order, buttonPositions };
525
559
  }
526
- createObfBoardFromPage(page, fallbackName, metadata) {
560
+ createObfBoardFromPage(page, fallbackName, metadata, embedData = false) {
527
561
  const { rows, columns, order, buttonPositions } = this.buildGridMetadata(page);
528
562
  const boardName = metadata?.name && page.id === metadata?.defaultHomePageId
529
563
  ? metadata.name
530
564
  : page.name || fallbackName;
565
+ let images = Array.isArray(page.images) ? page.images : [];
566
+ if (!embedData) {
567
+ images = images.map((image) => {
568
+ delete image.data;
569
+ return image;
570
+ });
571
+ }
531
572
  return {
532
573
  format: OBF_FORMAT_VERSION,
533
574
  id: page.id,
@@ -564,7 +605,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
564
605
  hidden: button.visibility === 'Hidden' || false,
565
606
  };
566
607
  }),
567
- images: Array.isArray(page.images) ? page.images : [],
608
+ images,
568
609
  sounds: Array.isArray(page.sounds) ? page.sounds : [],
569
610
  };
570
611
  }
@@ -601,23 +642,22 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
601
642
  await this.saveFromTree(tree, outputPath);
602
643
  return await readBinaryFromInput(outputPath);
603
644
  }
604
- async saveFromTree(tree, outputPath) {
605
- const { writeTextToPath, writeBinaryToPath, pathExists } = this.options.fileAdapter;
645
+ async saveFromTree(tree, outputPath, embedData = false) {
646
+ const { writeTextToPath, writeBinaryToPath, pathExists, mkDir, join } = this.options.fileAdapter;
606
647
  if (outputPath.endsWith('.obf')) {
607
648
  // Save as single OBF JSON file
608
649
  const rootPage = tree.rootId ? tree.getPage(tree.rootId) : Object.values(tree.pages)[0];
609
650
  if (!rootPage) {
610
651
  throw new Error('No pages to save');
611
652
  }
612
- const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata);
653
+ const obfBoard = this.createObfBoardFromPage(rootPage, 'Exported Board', tree.metadata, embedData);
613
654
  await writeTextToPath(outputPath, JSON.stringify(obfBoard, null, 2));
614
655
  }
615
656
  else {
616
- const getPageFilename = (id) => (id.endsWith('.obf') ? id : `${id}.obf`);
617
657
  const files = Object.values(tree.pages).map((page) => {
618
- const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
658
+ const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata, embedData);
619
659
  const obfContent = JSON.stringify(obfBoard, null, 2);
620
- const name = getPageFilename(page.id);
660
+ const name = this.getPageFilename(page.id, tree.metadata);
621
661
  return {
622
662
  name,
623
663
  data: new TextEncoder().encode(obfContent),
@@ -627,7 +667,10 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
627
667
  format: OBF_FORMAT_VERSION,
628
668
  root: tree.metadata.defaultHomePageId,
629
669
  paths: {
630
- boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])),
670
+ boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [
671
+ id,
672
+ this.getPageFilename(page.id, tree.metadata),
673
+ ])),
631
674
  images: {}, //TODO Add support for saving images as files
632
675
  sounds: {}, //TODO Add support for saving sounds as files
633
676
  },
@@ -636,10 +679,22 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
636
679
  name: 'manifest.json',
637
680
  data: new TextEncoder().encode(JSON.stringify(manifest)),
638
681
  });
639
- const fileExists = await pathExists(outputPath);
640
- this.zipFile = await this.options.zipAdapter(fileExists ? outputPath : undefined, this.options.fileAdapter);
641
- const zipData = await this.zipFile.writeFiles(files);
642
- await writeBinaryToPath(outputPath, zipData);
682
+ if (outputPath.endsWith('.obz') || outputPath.endsWith('.zip')) {
683
+ console.log('[OBF] Saving to ZIP file:', outputPath);
684
+ const fileExists = await pathExists(outputPath);
685
+ this.zipFile = await this.options.zipAdapter(fileExists ? outputPath : undefined, this.options.fileAdapter);
686
+ const zipData = await this.zipFile.writeFiles(files);
687
+ await writeBinaryToPath(outputPath, zipData);
688
+ }
689
+ else {
690
+ console.log('[OBF] Saving to directory:', outputPath);
691
+ if (!(await pathExists(outputPath)))
692
+ await mkDir(outputPath);
693
+ for (const file of files) {
694
+ const filePath = join(outputPath, file.name);
695
+ await writeBinaryToPath(filePath, file.data);
696
+ }
697
+ }
643
698
  }
644
699
  }
645
700
  /**
@@ -666,13 +721,12 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
666
721
  const AdmZip = (await Promise.resolve().then(() => __importStar(require('adm-zip')))).default;
667
722
  const originalZip = new AdmZip(originalPath);
668
723
  const outputZip = new AdmZip();
669
- const getPageFilename = (id) => (id.endsWith('.obf') ? id : `${id}.obf`);
670
724
  // Track which .obf files we're modifying
671
725
  const modifiedObfFiles = new Set();
672
726
  // Generate new .obf files for pages in the tree
673
727
  const newObfFiles = new Map();
674
728
  for (const page of Object.values(tree.pages)) {
675
- const obfFilename = getPageFilename(page.id);
729
+ const obfFilename = this.getPageFilename(page.id, tree.metadata);
676
730
  modifiedObfFiles.add(obfFilename);
677
731
  const obfBoard = this.createObfBoardFromPage(page, 'Board', tree.metadata);
678
732
  const obfContent = JSON.stringify(obfBoard, null, 2);
@@ -685,7 +739,10 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
685
739
  format: OBF_FORMAT_VERSION,
686
740
  root: tree.metadata.defaultHomePageId,
687
741
  paths: {
688
- boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [id, getPageFilename(page.id)])),
742
+ boards: Object.fromEntries(Object.entries(tree.pages).map(([id, page]) => [
743
+ id,
744
+ this.getPageFilename(page.id, tree.metadata),
745
+ ])),
689
746
  images: {},
690
747
  sounds: {},
691
748
  },
@@ -1,5 +1,4 @@
1
1
  export { MorphologyEngine } from './engine';
2
- export { Grid3VerbsParser } from './grid3VerbsParser';
3
2
  export { WordFormGenerator } from './wordFormGenerator';
4
3
  export type { MorphRuleSet, MorphRule, MorphWordForms, AstericsWordForm, VerbFormWithConditions, Grid3VerbFormsDetailed, } from './types';
5
4
  export type { Grid3VerbForms } from './grid3VerbsParser';