@willwade/aac-processors 0.0.21 → 0.0.23

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.
Files changed (32) hide show
  1. package/README.md +19 -27
  2. package/dist/core/treeStructure.d.ts +2 -2
  3. package/dist/core/treeStructure.js +4 -1
  4. package/dist/processors/applePanelsProcessor.js +166 -123
  5. package/dist/processors/astericsGridProcessor.js +121 -105
  6. package/dist/processors/dotProcessor.js +83 -65
  7. package/dist/processors/gridsetProcessor.js +2 -0
  8. package/dist/processors/obfProcessor.js +11 -4
  9. package/dist/processors/opmlProcessor.js +82 -44
  10. package/dist/processors/snapProcessor.js +19 -9
  11. package/dist/processors/touchchatProcessor.js +72 -21
  12. package/dist/utilities/analytics/metrics/core.d.ts +1 -1
  13. package/dist/utilities/analytics/metrics/core.js +191 -212
  14. package/dist/validation/applePanelsValidator.d.ts +10 -0
  15. package/dist/validation/applePanelsValidator.js +124 -0
  16. package/dist/validation/astericsValidator.d.ts +16 -0
  17. package/dist/validation/astericsValidator.js +115 -0
  18. package/dist/validation/dotValidator.d.ts +10 -0
  19. package/dist/validation/dotValidator.js +113 -0
  20. package/dist/validation/excelValidator.d.ts +10 -0
  21. package/dist/validation/excelValidator.js +89 -0
  22. package/dist/validation/index.d.ts +14 -1
  23. package/dist/validation/index.js +104 -1
  24. package/dist/validation/obfsetValidator.d.ts +10 -0
  25. package/dist/validation/obfsetValidator.js +103 -0
  26. package/dist/validation/opmlValidator.d.ts +10 -0
  27. package/dist/validation/opmlValidator.js +107 -0
  28. package/dist/validation/validationTypes.d.ts +22 -0
  29. package/dist/validation/validationTypes.js +38 -1
  30. package/dist/validation.d.ts +8 -2
  31. package/dist/validation.js +16 -1
  32. package/package.json +1 -1
@@ -12,6 +12,8 @@ exports.getContrastingTextColor = getContrastingTextColor;
12
12
  const baseProcessor_1 = require("../core/baseProcessor");
13
13
  const treeStructure_1 = require("../core/treeStructure");
14
14
  const fs_1 = __importDefault(require("fs"));
15
+ const path_1 = __importDefault(require("path"));
16
+ const validation_1 = require("../validation");
15
17
  const DEFAULT_COLOR_SCHEME_DEFINITIONS = [
16
18
  {
17
19
  name: 'CS_MODIFIED_FITZGERALD_KEY_VERY_LIGHT',
@@ -664,122 +666,136 @@ class AstericsGridProcessor extends baseProcessor_1.BaseProcessor {
664
666
  }
665
667
  loadIntoTree(filePathOrBuffer) {
666
668
  const tree = new treeStructure_1.AACTree();
667
- let content = Buffer.isBuffer(filePathOrBuffer)
668
- ? filePathOrBuffer.toString('utf-8')
669
- : fs_1.default.readFileSync(filePathOrBuffer, 'utf-8');
670
- // Remove BOM if present
671
- if (content.charCodeAt(0) === 0xfeff) {
672
- content = content.slice(1);
673
- }
674
- const grdFile = JSON.parse(content);
675
- if (!grdFile.grids) {
676
- return tree;
677
- }
678
- const rawColorConfig = grdFile.metadata?.colorConfig;
679
- const colorConfig = isRecord(rawColorConfig)
680
- ? rawColorConfig
681
- : undefined;
682
- const activeColorSchemeDefinition = getActiveColorSchemeDefinition(colorConfig);
683
- // First pass: create all pages
684
- grdFile.grids.forEach((grid) => {
685
- const page = new treeStructure_1.AACPage({
686
- id: grid.id,
687
- name: this.getLocalizedLabel(grid.label) || grid.id,
688
- grid: [],
689
- buttons: [],
690
- parentId: null,
691
- style: {
692
- backgroundColor: colorConfig?.gridBackgroundColor || '#FFFFFF',
693
- borderColor: colorConfig?.elementBorderColor || '#CCCCCC',
694
- borderWidth: colorConfig?.borderWidth || 1,
695
- fontFamily: colorConfig?.fontFamily || 'Arial',
696
- fontSize: colorConfig?.fontSizePct ? colorConfig.fontSizePct * 16 : 16, // Convert percentage to pixels, default to 16
697
- fontColor: colorConfig?.fontColor || '#000000',
698
- },
699
- });
700
- tree.addPage(page);
701
- });
702
- // Second pass: add buttons and establish navigation
703
- grdFile.grids.forEach((grid) => {
704
- const page = tree.getPage(grid.id);
705
- if (!page)
706
- return;
707
- // Create a 2D grid to track button positions
708
- const gridLayout = [];
709
- const maxRows = Math.max(10, grid.rowCount || 10);
710
- const maxCols = Math.max(10, grid.minColumnCount || 10);
711
- for (let r = 0; r < maxRows; r++) {
712
- gridLayout[r] = new Array(maxCols).fill(null);
669
+ const filename = typeof filePathOrBuffer === 'string' ? path_1.default.basename(filePathOrBuffer) : 'upload.grd';
670
+ const buffer = Buffer.isBuffer(filePathOrBuffer)
671
+ ? filePathOrBuffer
672
+ : fs_1.default.readFileSync(filePathOrBuffer);
673
+ try {
674
+ let content = buffer.toString('utf-8');
675
+ // Remove BOM if present
676
+ if (content.charCodeAt(0) === 0xfeff) {
677
+ content = content.slice(1);
713
678
  }
714
- grid.gridElements.forEach((element) => {
715
- const button = this.createButtonFromElement(element, colorConfig, activeColorSchemeDefinition);
716
- page.addButton(button);
717
- // Place button in grid layout using its x,y coordinates
718
- const buttonX = element.x || 0;
719
- const buttonY = element.y || 0;
720
- const buttonWidth = element.width || 1;
721
- const buttonHeight = element.height || 1;
722
- // Place button in grid (handle width/height span)
723
- for (let r = buttonY; r < buttonY + buttonHeight && r < maxRows; r++) {
724
- for (let c = buttonX; c < buttonX + buttonWidth && c < maxCols; c++) {
725
- if (gridLayout[r] && gridLayout[r][c] === null) {
726
- gridLayout[r][c] = button;
679
+ const grdFile = JSON.parse(content);
680
+ if (!grdFile.grids) {
681
+ const validationResult = (0, validation_1.buildValidationResultFromMessage)({
682
+ filename,
683
+ filesize: buffer.byteLength,
684
+ format: 'asterics',
685
+ message: 'Missing grids array in Asterics .grd file',
686
+ type: 'structure',
687
+ description: 'Asterics grid collection',
688
+ });
689
+ throw new validation_1.ValidationFailureError('Invalid Asterics grid file', validationResult);
690
+ }
691
+ const rawColorConfig = grdFile.metadata?.colorConfig;
692
+ const colorConfig = isRecord(rawColorConfig)
693
+ ? rawColorConfig
694
+ : undefined;
695
+ const activeColorSchemeDefinition = getActiveColorSchemeDefinition(colorConfig);
696
+ grdFile.grids.forEach((grid) => {
697
+ const page = new treeStructure_1.AACPage({
698
+ id: grid.id,
699
+ name: this.getLocalizedLabel(grid.label) || grid.id,
700
+ grid: [],
701
+ buttons: [],
702
+ parentId: null,
703
+ style: {
704
+ backgroundColor: colorConfig?.gridBackgroundColor || '#FFFFFF',
705
+ borderColor: colorConfig?.elementBorderColor || '#CCCCCC',
706
+ borderWidth: colorConfig?.borderWidth || 1,
707
+ fontFamily: colorConfig?.fontFamily || 'Arial',
708
+ fontSize: colorConfig?.fontSizePct ? colorConfig.fontSizePct * 16 : 16,
709
+ fontColor: colorConfig?.fontColor || '#000000',
710
+ },
711
+ });
712
+ tree.addPage(page);
713
+ });
714
+ grdFile.grids.forEach((grid) => {
715
+ const page = tree.getPage(grid.id);
716
+ if (!page)
717
+ return;
718
+ const gridLayout = [];
719
+ const maxRows = Math.max(10, grid.rowCount || 10);
720
+ const maxCols = Math.max(10, grid.minColumnCount || 10);
721
+ for (let r = 0; r < maxRows; r++) {
722
+ gridLayout[r] = new Array(maxCols).fill(null);
723
+ }
724
+ grid.gridElements.forEach((element) => {
725
+ const button = this.createButtonFromElement(element, colorConfig, activeColorSchemeDefinition);
726
+ page.addButton(button);
727
+ const buttonX = element.x || 0;
728
+ const buttonY = element.y || 0;
729
+ const buttonWidth = element.width || 1;
730
+ const buttonHeight = element.height || 1;
731
+ for (let r = buttonY; r < buttonY + buttonHeight && r < maxRows; r++) {
732
+ for (let c = buttonX; c < buttonX + buttonWidth && c < maxCols; c++) {
733
+ if (gridLayout[r] && gridLayout[r][c] === null) {
734
+ gridLayout[r][c] = button;
735
+ }
727
736
  }
728
737
  }
729
- }
730
- // Handle navigation relationships
731
- const navAction = element.actions.find((a) => a.modelName === 'GridActionNavigate');
732
- const targetGridId = navAction && typeof navAction.toGridId === 'string' ? navAction.toGridId : undefined;
733
- if (targetGridId) {
734
- const targetPage = tree.getPage(targetGridId);
735
- if (targetPage) {
736
- targetPage.parentId = page.id;
738
+ const navAction = element.actions.find((a) => a.modelName === 'GridActionNavigate');
739
+ const targetGridId = navAction && typeof navAction.toGridId === 'string' ? navAction.toGridId : undefined;
740
+ if (targetGridId) {
741
+ const targetPage = tree.getPage(targetGridId);
742
+ if (targetPage) {
743
+ targetPage.parentId = page.id;
744
+ }
737
745
  }
738
- }
746
+ });
747
+ page.grid = gridLayout;
739
748
  });
740
- // Set the page's grid layout
741
- page.grid = gridLayout;
742
- });
743
- // Set metadata for Asterics Grid files
744
- const astericsMetadata = {
745
- format: 'asterics',
746
- hasGlobalGrid: false, // Can be extended in the future
747
- };
748
- if (grdFile.grids && grdFile.grids.length > 0) {
749
- astericsMetadata.name = this.getLocalizedLabel(grdFile.grids[0].label);
750
- // Extract all unique languages from all grids and elements
751
- const languages = new Set();
752
- grdFile.grids.forEach((grid) => {
753
- if (grid.label) {
754
- Object.keys(grid.label).forEach((lang) => languages.add(lang));
755
- }
756
- grid.gridElements?.forEach((element) => {
757
- if (element.label) {
758
- Object.keys(element.label).forEach((lang) => languages.add(lang));
749
+ const astericsMetadata = {
750
+ format: 'asterics',
751
+ hasGlobalGrid: false,
752
+ };
753
+ if (grdFile.grids && grdFile.grids.length > 0) {
754
+ astericsMetadata.name = this.getLocalizedLabel(grdFile.grids[0].label);
755
+ const languages = new Set();
756
+ grdFile.grids.forEach((grid) => {
757
+ if (grid.label) {
758
+ Object.keys(grid.label).forEach((lang) => languages.add(lang));
759
759
  }
760
- // Also check word forms for languages
761
- element.wordForms?.forEach((wf) => {
762
- if (wf.lang)
763
- languages.add(wf.lang);
760
+ grid.gridElements?.forEach((element) => {
761
+ if (element.label) {
762
+ Object.keys(element.label).forEach((lang) => languages.add(lang));
763
+ }
764
+ element.wordForms?.forEach((wf) => {
765
+ if (wf.lang)
766
+ languages.add(wf.lang);
767
+ });
764
768
  });
765
769
  });
766
- });
767
- if (languages.size > 0) {
768
- astericsMetadata.languages = Array.from(languages).sort();
769
- // Set primary locale to English if available, otherwise the first language found
770
- astericsMetadata.locale = languages.has('en')
771
- ? 'en'
772
- : languages.has('de')
773
- ? 'de'
774
- : astericsMetadata.languages[0];
770
+ if (languages.size > 0) {
771
+ astericsMetadata.languages = Array.from(languages).sort();
772
+ astericsMetadata.locale = languages.has('en')
773
+ ? 'en'
774
+ : languages.has('de')
775
+ ? 'de'
776
+ : astericsMetadata.languages[0];
777
+ }
778
+ }
779
+ tree.metadata = astericsMetadata;
780
+ if (grdFile.metadata && grdFile.metadata.homeGridId) {
781
+ tree.rootId = grdFile.metadata.homeGridId;
775
782
  }
783
+ return tree;
776
784
  }
777
- tree.metadata = astericsMetadata;
778
- // Set the home page from metadata.homeGridId
779
- if (grdFile.metadata && grdFile.metadata.homeGridId) {
780
- tree.rootId = grdFile.metadata.homeGridId;
785
+ catch (err) {
786
+ if (err instanceof validation_1.ValidationFailureError) {
787
+ throw err;
788
+ }
789
+ const validationResult = (0, validation_1.buildValidationResultFromMessage)({
790
+ filename,
791
+ filesize: buffer.byteLength,
792
+ format: 'asterics',
793
+ message: err?.message || 'Failed to parse Asterics grid file',
794
+ type: 'parse',
795
+ description: 'Parse Asterics grid JSON',
796
+ });
797
+ throw new validation_1.ValidationFailureError('Failed to load Asterics grid', validationResult, err);
781
798
  }
782
- return tree;
783
799
  }
784
800
  getLocalizedLabel(labelMap) {
785
801
  if (!labelMap)
@@ -8,6 +8,8 @@ const baseProcessor_1 = require("../core/baseProcessor");
8
8
  const treeStructure_1 = require("../core/treeStructure");
9
9
  // Removed unused import: FileProcessor
10
10
  const fs_1 = __importDefault(require("fs"));
11
+ const path_1 = __importDefault(require("path"));
12
+ const validation_1 = require("../validation");
11
13
  class DotProcessor extends baseProcessor_1.BaseProcessor {
12
14
  constructor(options) {
13
15
  super(options);
@@ -72,78 +74,94 @@ class DotProcessor extends baseProcessor_1.BaseProcessor {
72
74
  return texts;
73
75
  }
74
76
  loadIntoTree(filePathOrBuffer) {
75
- let content;
77
+ const filename = typeof filePathOrBuffer === 'string' ? path_1.default.basename(filePathOrBuffer) : 'upload.dot';
78
+ const buffer = Buffer.isBuffer(filePathOrBuffer)
79
+ ? filePathOrBuffer
80
+ : fs_1.default.readFileSync(filePathOrBuffer);
81
+ const filesize = buffer.byteLength;
76
82
  try {
77
- content =
78
- typeof filePathOrBuffer === 'string'
79
- ? fs_1.default.readFileSync(filePathOrBuffer, 'utf8')
80
- : filePathOrBuffer.toString('utf8');
83
+ const content = buffer.toString('utf8');
84
+ if (!content || content.trim().length === 0) {
85
+ const validation = (0, validation_1.buildValidationResultFromMessage)({
86
+ filename,
87
+ filesize,
88
+ format: 'dot',
89
+ message: 'DOT file is empty',
90
+ type: 'content',
91
+ description: 'DOT file content',
92
+ });
93
+ throw new validation_1.ValidationFailureError('Empty DOT content', validation);
94
+ }
95
+ // Check for binary data (contains null bytes or non-printable characters)
96
+ const head = content.substring(0, 100);
97
+ for (let i = 0; i < head.length; i++) {
98
+ const code = head.charCodeAt(i);
99
+ if (code === 0 || (code >= 0 && code <= 8) || (code >= 14 && code <= 31)) {
100
+ const validation = (0, validation_1.buildValidationResultFromMessage)({
101
+ filename,
102
+ filesize,
103
+ format: 'dot',
104
+ message: 'DOT appears to be binary data',
105
+ type: 'content',
106
+ description: 'DOT file content',
107
+ });
108
+ throw new validation_1.ValidationFailureError('Invalid DOT content', validation);
109
+ }
110
+ }
111
+ const { nodes, edges } = this.parseDotFile(content);
112
+ const tree = new treeStructure_1.AACTree();
113
+ tree.metadata.format = 'dot';
114
+ // Create pages for each node and add a self button representing the node label
115
+ for (const node of nodes) {
116
+ const page = new treeStructure_1.AACPage({
117
+ id: node.id,
118
+ name: node.label,
119
+ grid: [],
120
+ buttons: [],
121
+ parentId: null,
122
+ });
123
+ tree.addPage(page);
124
+ // Add a self button so single-node graphs yield one button
125
+ page.addButton(new treeStructure_1.AACButton({
126
+ id: `${node.id}_self`,
127
+ label: node.label,
128
+ message: node.label,
129
+ semanticAction: {
130
+ intent: treeStructure_1.AACSemanticIntent.SPEAK_TEXT,
131
+ text: node.label,
132
+ fallback: { type: 'SPEAK', message: node.label },
133
+ },
134
+ }));
135
+ }
136
+ // Create navigation buttons based on edges
137
+ for (const edge of edges) {
138
+ const fromPage = tree.getPage(edge.from);
139
+ if (fromPage) {
140
+ const button = new treeStructure_1.AACButton({
141
+ id: `nav_${edge.from}_${edge.to}`,
142
+ label: edge.label || edge.to,
143
+ message: '',
144
+ targetPageId: edge.to,
145
+ });
146
+ fromPage.addButton(button);
147
+ }
148
+ }
149
+ return tree;
81
150
  }
82
151
  catch (error) {
83
- // Re-throw file system errors (like file not found)
84
- if (typeof filePathOrBuffer === 'string') {
152
+ if (error instanceof validation_1.ValidationFailureError) {
85
153
  throw error;
86
154
  }
87
- // For buffer errors, return empty tree
88
- return new treeStructure_1.AACTree();
89
- }
90
- // Check if content looks like text and is non-empty
91
- if (!content || content.trim().length === 0) {
92
- return new treeStructure_1.AACTree();
93
- }
94
- // Check for binary data (contains null bytes or non-printable characters) without control-regex
95
- const head = content.substring(0, 100);
96
- let hasControl = false;
97
- for (let i = 0; i < head.length; i++) {
98
- const code = head.charCodeAt(i);
99
- // Allow UTF-8 characters (code >= 127)
100
- if (code === 0 || (code >= 0 && code <= 8) || (code >= 14 && code <= 31)) {
101
- hasControl = true;
102
- break;
103
- }
104
- }
105
- if (hasControl) {
106
- return new treeStructure_1.AACTree();
107
- }
108
- const { nodes, edges } = this.parseDotFile(content);
109
- const tree = new treeStructure_1.AACTree();
110
- tree.metadata.format = 'dot';
111
- // Create pages for each node and add a self button representing the node label
112
- for (const node of nodes) {
113
- const page = new treeStructure_1.AACPage({
114
- id: node.id,
115
- name: node.label,
116
- grid: [],
117
- buttons: [],
118
- parentId: null,
155
+ const validation = (0, validation_1.buildValidationResultFromMessage)({
156
+ filename,
157
+ filesize,
158
+ format: 'dot',
159
+ message: error?.message || 'Failed to parse DOT file',
160
+ type: 'parse',
161
+ description: 'Parse DOT graph',
119
162
  });
120
- tree.addPage(page);
121
- // Add a self button so single-node graphs yield one button
122
- page.addButton(new treeStructure_1.AACButton({
123
- id: `${node.id}_self`,
124
- label: node.label,
125
- message: node.label,
126
- semanticAction: {
127
- intent: treeStructure_1.AACSemanticIntent.SPEAK_TEXT,
128
- text: node.label,
129
- fallback: { type: 'SPEAK', message: node.label },
130
- },
131
- }));
132
- }
133
- // Create navigation buttons based on edges
134
- for (const edge of edges) {
135
- const fromPage = tree.getPage(edge.from);
136
- if (fromPage) {
137
- const button = new treeStructure_1.AACButton({
138
- id: `nav_${edge.from}_${edge.to}`,
139
- label: edge.label || edge.to,
140
- message: '',
141
- targetPageId: edge.to,
142
- });
143
- fromPage.addButton(button);
144
- }
163
+ throw new validation_1.ValidationFailureError('Failed to load DOT file', validation, error);
145
164
  }
146
- return tree;
147
165
  }
148
166
  processTexts(filePathOrBuffer, translations, outputPath) {
149
167
  const safeBuffer = Buffer.isBuffer(filePathOrBuffer)
@@ -1400,6 +1400,8 @@ class GridsetProcessor extends baseProcessor_1.BaseProcessor {
1400
1400
  const homeGridId = gridNameToIdMap.get(startGridName);
1401
1401
  if (homeGridId) {
1402
1402
  metadata.defaultHomePageId = homeGridId;
1403
+ // Also set tree.rootId so BoardViewer knows which page to show first
1404
+ tree.rootId = homeGridId;
1403
1405
  }
1404
1406
  }
1405
1407
  const keyboardGridName = settingsData?.GridSetSettings?.KeyboardGrid ||
@@ -29,6 +29,12 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
29
29
  }
30
30
  processBoard(boardData, _boardPath) {
31
31
  const sourceButtons = boardData.buttons || [];
32
+ // Calculate page ID first (used to make button IDs unique)
33
+ const pageId = _boardPath && _boardPath.endsWith('.obf') && !_boardPath.includes('/')
34
+ ? _boardPath // Zip entry - use filename to match navigation paths
35
+ : boardData?.id
36
+ ? String(boardData.id)
37
+ : _boardPath?.split('/').pop() || '';
32
38
  const buttons = sourceButtons.map((btn) => {
33
39
  const semanticAction = btn.load_board
34
40
  ? {
@@ -50,7 +56,8 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
50
56
  },
51
57
  };
52
58
  return new treeStructure_1.AACButton({
53
- id: String(btn?.id || ''),
59
+ // Make button ID unique by combining page ID and button ID
60
+ id: `${pageId}::${btn?.id || ''}`,
54
61
  label: String(btn?.label || ''),
55
62
  message: String(btn?.vocalization || btn?.label || ''),
56
63
  visibility: mapObfVisibility(btn.hidden),
@@ -65,7 +72,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
65
72
  });
66
73
  const buttonMap = new Map(buttons.map((btn) => [btn.id, btn]));
67
74
  const page = new treeStructure_1.AACPage({
68
- id: String(boardData?.id || ''),
75
+ id: pageId, // Use the page ID we calculated earlier
69
76
  name: String(boardData?.name || ''),
70
77
  grid: [],
71
78
  buttons,
@@ -96,7 +103,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
96
103
  return;
97
104
  if (rowIndex >= rows || colIndex >= cols)
98
105
  return;
99
- const aacBtn = buttonMap.get(String(cellId));
106
+ const aacBtn = buttonMap.get(`${pageId}::${cellId}`);
100
107
  if (aacBtn) {
101
108
  grid[rowIndex][colIndex] = aacBtn;
102
109
  }
@@ -109,7 +116,7 @@ class ObfProcessor extends baseProcessor_1.BaseProcessor {
109
116
  const row = Math.floor(btn.box_id / cols);
110
117
  const col = btn.box_id % cols;
111
118
  if (row < rows && col < cols) {
112
- const aacBtn = buttonMap.get(String(btn.id));
119
+ const aacBtn = buttonMap.get(`${pageId}::${btn.id}`);
113
120
  if (aacBtn) {
114
121
  grid[row][col] = aacBtn;
115
122
  }
@@ -9,6 +9,8 @@ const treeStructure_1 = require("../core/treeStructure");
9
9
  // Removed unused import: FileProcessor
10
10
  const fast_xml_parser_1 = require("fast-xml-parser");
11
11
  const fs_1 = __importDefault(require("fs"));
12
+ const path_1 = __importDefault(require("path"));
13
+ const validation_1 = require("../validation");
12
14
  class OpmlProcessor extends baseProcessor_1.BaseProcessor {
13
15
  constructor(options) {
14
16
  super(options);
@@ -88,55 +90,91 @@ class OpmlProcessor extends baseProcessor_1.BaseProcessor {
88
90
  return texts;
89
91
  }
90
92
  loadIntoTree(filePathOrBuffer) {
91
- const content = typeof filePathOrBuffer === 'string'
92
- ? fs_1.default.readFileSync(filePathOrBuffer, 'utf8')
93
- : filePathOrBuffer.toString('utf8');
94
- if (!content || !content.trim()) {
95
- throw new Error('Empty OPML content');
96
- }
97
- // Validate XML before parsing, fast-xml-parser is permissive by default
98
- const validationResult = fast_xml_parser_1.XMLValidator.validate(content);
99
- if (validationResult !== true) {
100
- const reason = validationResult?.err?.msg || JSON.stringify(validationResult);
101
- throw new Error(`Invalid OPML XML: ${reason}`);
102
- }
103
- const parser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false });
104
- let data;
93
+ const filename = typeof filePathOrBuffer === 'string' ? path_1.default.basename(filePathOrBuffer) : 'upload.opml';
94
+ const buffer = Buffer.isBuffer(filePathOrBuffer)
95
+ ? filePathOrBuffer
96
+ : fs_1.default.readFileSync(filePathOrBuffer);
97
+ const content = buffer.toString('utf8');
105
98
  try {
106
- data = parser.parse(content);
107
- }
108
- catch (e) {
109
- throw new Error(`Invalid OPML XML: ${e?.message || String(e)}`);
110
- }
111
- const tree = new treeStructure_1.AACTree();
112
- tree.metadata.format = 'opml';
113
- // Handle case where body.outline might not exist or be in different formats
114
- const bodyOutline = data.opml?.body?.outline;
115
- if (!bodyOutline) {
116
- return tree; // Return empty tree if no outline data
117
- }
118
- const outlines = Array.isArray(bodyOutline) ? bodyOutline : [bodyOutline];
119
- let firstRootId = null;
120
- outlines.forEach((outline) => {
121
- const { page, childPages } = this.processOutline(outline);
122
- if (page && page.id) {
123
- tree.addPage(page);
124
- if (!firstRootId)
125
- firstRootId = page.id;
99
+ if (!content || !content.trim()) {
100
+ const validationResult = (0, validation_1.buildValidationResultFromMessage)({
101
+ filename,
102
+ filesize: buffer.byteLength,
103
+ format: 'opml',
104
+ message: 'Empty OPML content',
105
+ type: 'content',
106
+ description: 'OPML content is empty',
107
+ });
108
+ throw new validation_1.ValidationFailureError('Empty OPML content', validationResult);
126
109
  }
127
- childPages.forEach((childPage) => {
128
- if (childPage && childPage.id)
129
- tree.addPage(childPage);
110
+ // Validate XML before parsing, fast-xml-parser is permissive by default
111
+ const validationResult = fast_xml_parser_1.XMLValidator.validate(content);
112
+ if (validationResult !== true) {
113
+ const reason = validationResult?.err?.msg || JSON.stringify(validationResult);
114
+ const structured = (0, validation_1.buildValidationResultFromMessage)({
115
+ filename,
116
+ filesize: buffer.byteLength,
117
+ format: 'opml',
118
+ message: `Invalid OPML XML: ${reason}`,
119
+ type: 'xml',
120
+ description: 'OPML XML validation',
121
+ });
122
+ throw new validation_1.ValidationFailureError('Invalid OPML XML', structured);
123
+ }
124
+ const parser = new fast_xml_parser_1.XMLParser({ ignoreAttributes: false });
125
+ const data = parser.parse(content);
126
+ const tree = new treeStructure_1.AACTree();
127
+ tree.metadata.format = 'opml';
128
+ // Handle case where body.outline might not exist or be in different formats
129
+ const bodyOutline = data.opml?.body?.outline;
130
+ if (!bodyOutline) {
131
+ const structured = (0, validation_1.buildValidationResultFromMessage)({
132
+ filename,
133
+ filesize: buffer.byteLength,
134
+ format: 'opml',
135
+ message: 'Missing body.outline in OPML document',
136
+ type: 'structure',
137
+ description: 'OPML outline root',
138
+ });
139
+ throw new validation_1.ValidationFailureError('Invalid OPML structure', structured);
140
+ }
141
+ const outlines = Array.isArray(bodyOutline) ? bodyOutline : [bodyOutline];
142
+ let firstRootId = null;
143
+ outlines.forEach((outline) => {
144
+ const { page, childPages } = this.processOutline(outline);
145
+ if (page && page.id) {
146
+ tree.addPage(page);
147
+ if (!firstRootId)
148
+ firstRootId = page.id;
149
+ }
150
+ childPages.forEach((childPage) => {
151
+ if (childPage && childPage.id)
152
+ tree.addPage(childPage);
153
+ });
130
154
  });
131
- });
132
- // Set rootId to first root page, or fallback to first page if any exist
133
- if (firstRootId) {
134
- tree.rootId = firstRootId;
155
+ // Set rootId to first root page, or fallback to first page if any exist
156
+ if (firstRootId) {
157
+ tree.rootId = firstRootId;
158
+ }
159
+ else if (Object.keys(tree.pages).length > 0) {
160
+ tree.rootId = Object.keys(tree.pages)[0];
161
+ }
162
+ return tree;
135
163
  }
136
- else if (Object.keys(tree.pages).length > 0) {
137
- tree.rootId = Object.keys(tree.pages)[0];
164
+ catch (err) {
165
+ if (err instanceof validation_1.ValidationFailureError) {
166
+ throw err;
167
+ }
168
+ const validationResult = (0, validation_1.buildValidationResultFromMessage)({
169
+ filename,
170
+ filesize: buffer.byteLength,
171
+ format: 'opml',
172
+ message: err?.message || 'Failed to parse OPML',
173
+ type: 'parse',
174
+ description: 'Parse OPML XML',
175
+ });
176
+ throw new validation_1.ValidationFailureError('Failed to load OPML file', validationResult, err);
138
177
  }
139
- return tree;
140
178
  }
141
179
  processTexts(filePathOrBuffer, translations, outputPath) {
142
180
  const content = typeof filePathOrBuffer === 'string'