ocr-upload.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. /**
  2. * @node ocr-upload
  3. * @name OCR Upload
  4. * @category ocr
  5. * @version 1.2.0
  6. * @description Upload a file for OCR processing via pdftoimageapi
  7. * @icon file-scan
  8. */
  9. const configSchema = {
  10. type: 'object',
  11. properties: {
  12. fileSource: {
  13. type: 'string',
  14. title: 'File Source',
  15. description: 'Path to file data (e.g., {{item.attachments[0]}} for loop iterations, or {{data.attachments[0]}} for direct input). Leave empty to auto-detect from input.'
  16. },
  17. baseUrl: {
  18. type: 'string',
  19. title: 'API Base URL',
  20. default: 'http://localhost:3000',
  21. description: 'Base URL of the OCR API'
  22. },
  23. credentialId: {
  24. type: 'string',
  25. title: 'Credential',
  26. description: 'Bearer or API Key credential for authentication',
  27. dynamicOptions: {
  28. source: 'credentials',
  29. filter: { type: 'api_key' }
  30. }
  31. },
  32. processingMode: {
  33. type: 'string',
  34. title: 'Processing Mode',
  35. enum: ['auto', 'single', 'batch', 'smart'],
  36. default: 'auto',
  37. description: 'How to process the uploaded file'
  38. },
  39. batchSize: {
  40. type: 'number',
  41. title: 'Batch Size',
  42. default: 5,
  43. description: 'Number of pages per batch (for batch mode)'
  44. },
  45. targetWidth: {
  46. type: 'number',
  47. title: 'Target Width',
  48. description: 'Target width in pixels for image conversion'
  49. },
  50. groupId: {
  51. type: 'string',
  52. title: 'Group ID',
  53. description: 'Optional group ID to associate jobs'
  54. }
  55. },
  56. required: ['baseUrl']
  57. };
  58. const inputSchema = {
  59. type: 'object',
  60. properties: {
  61. file: {
  62. type: 'object',
  63. description: 'Binary file object with type, data (base64), mimeType, filename, size'
  64. },
  65. storage: {
  66. type: 'object',
  67. description: 'Storage reference to retrieve file from database',
  68. properties: {
  69. collection: { type: 'string' },
  70. id: { type: 'string' }
  71. }
  72. }
  73. }
  74. };
  75. const outputSchema = {
  76. type: 'object',
  77. properties: {
  78. job: {
  79. type: 'object',
  80. properties: {
  81. id: { type: 'string' },
  82. status: { type: 'string' },
  83. filename: { type: 'string' },
  84. created_at: { type: 'string' }
  85. }
  86. }
  87. }
  88. };
  89. const outputs = [
  90. { name: 'main', displayName: 'Job Created', type: 'object', color: '#10b981' }
  91. ];
  92. function isBinaryInput(input) {
  93. // Binary input can have either data (base64) or filePath (disk reference)
  94. return input && input.type === 'binary' && (typeof input.data === 'string' || typeof input.filePath === 'string');
  95. }
  96. function resolveBinaryData(file) {
  97. // If file has filePath, read from disk
  98. if (file.filePath && !file.data) {
  99. const readResult = smartbotic.fs.readFile(file.filePath, 'base64');
  100. if (readResult.success) {
  101. file.data = readResult.data;
  102. smartbotic.log.info(`Loaded binary from disk: ${file.filePath}`);
  103. } else {
  104. throw new Error(`Failed to read file from disk: ${file.filePath} - ${readResult.error}`);
  105. }
  106. }
  107. return file;
  108. }
  109. function generateBoundary() {
  110. return '----SmartBoticBoundary' + smartbotic.utils.uuid().replace(/-/g, '');
  111. }
  112. function buildMultipartBody(boundary, file, options) {
  113. const crlf = '\r\n';
  114. let body = '';
  115. body += `--${boundary}${crlf}`;
  116. body += `Content-Disposition: form-data; name="file"; filename="${file.filename}"${crlf}`;
  117. body += `Content-Type: ${file.mimeType}${crlf}`;
  118. body += `Content-Transfer-Encoding: base64${crlf}`;
  119. body += crlf;
  120. body += file.data;
  121. body += crlf;
  122. if (options.processingMode && options.processingMode !== 'auto') {
  123. body += `--${boundary}${crlf}`;
  124. body += `Content-Disposition: form-data; name="processing_mode"${crlf}`;
  125. body += crlf;
  126. body += options.processingMode;
  127. body += crlf;
  128. }
  129. if (options.batchSize) {
  130. body += `--${boundary}${crlf}`;
  131. body += `Content-Disposition: form-data; name="batch_size"${crlf}`;
  132. body += crlf;
  133. body += String(options.batchSize);
  134. body += crlf;
  135. }
  136. if (options.targetWidth) {
  137. body += `--${boundary}${crlf}`;
  138. body += `Content-Disposition: form-data; name="target_width"${crlf}`;
  139. body += crlf;
  140. body += String(options.targetWidth);
  141. body += crlf;
  142. }
  143. if (options.groupId) {
  144. body += `--${boundary}${crlf}`;
  145. body += `Content-Disposition: form-data; name="group_id"${crlf}`;
  146. body += crlf;
  147. body += options.groupId;
  148. body += crlf;
  149. }
  150. body += `--${boundary}--${crlf}`;
  151. return body;
  152. }
  153. module.exports = {
  154. configSchema,
  155. inputSchema,
  156. outputSchema,
  157. outputs,
  158. async execute(config, input, context) {
  159. const baseUrl = (config.baseUrl || 'http://localhost:3000').replace(/\/$/, '');
  160. let file = null;
  161. // First priority: use configured fileSource if provided
  162. // The expression (e.g., {{item.attachments[0]}}) is evaluated by the workflow engine,
  163. // so config.fileSource contains the actual file object
  164. if (config.fileSource && typeof config.fileSource === 'object') {
  165. if (isBinaryInput(config.fileSource)) {
  166. file = config.fileSource;
  167. smartbotic.log.info('OCR Upload: Using configured file source');
  168. } else if (Array.isArray(config.fileSource) && config.fileSource.length > 0) {
  169. // If fileSource resolved to an array (e.g., attachments array), use first item
  170. file = config.fileSource[0];
  171. smartbotic.log.info(`OCR Upload: Using first item from configured file source array: ${file.filename}`);
  172. }
  173. }
  174. // Fallback: auto-detect from various input formats
  175. if (!file) {
  176. if (isBinaryInput(input.file)) {
  177. file = input.file;
  178. } else if (isBinaryInput(input)) {
  179. file = input;
  180. } else if (input.data && isBinaryInput(input.data)) {
  181. // Handle data wrapper from upstream nodes
  182. file = input.data;
  183. } else if (input.attachments && Array.isArray(input.attachments) && input.attachments.length > 0) {
  184. // Handle output from IMAP Extract Attachments - use first attachment
  185. file = input.attachments[0];
  186. smartbotic.log.info(`Using first attachment from array: ${file.filename}`);
  187. } else if (input.storage && input.storage.collection && input.storage.id) {
  188. // Load from storage database (metadata with filePath)
  189. const storageResult = smartbotic.storage.get(input.storage.collection, input.storage.id);
  190. if (!storageResult.found) {
  191. throw new Error(`File not found in storage: ${input.storage.collection}/${input.storage.id}`);
  192. }
  193. const doc = storageResult.document;
  194. file = {
  195. type: 'binary',
  196. filePath: doc.filePath, // Reference to disk location
  197. mimeType: doc.mimeType,
  198. filename: doc.filename,
  199. size: doc.size
  200. };
  201. }
  202. }
  203. if (!file) {
  204. // Log available input keys for debugging
  205. const inputKeys = Object.keys(input || {}).join(', ');
  206. smartbotic.log.warn(`OCR Upload: No binary file found. Input keys: ${inputKeys}`);
  207. throw new Error('No file provided. Configure the "File Source" field with an expression like {{item.attachments[0]}} for loop iterations, or connect to IMAP Extract Attachments.');
  208. }
  209. // Resolve file data from disk if needed
  210. file = resolveBinaryData(file);
  211. const headers = {};
  212. if (config.credentialId) {
  213. const auth = smartbotic.credentials.get(config.credentialId);
  214. if (!auth.success) {
  215. throw new Error(`Failed to load credential: ${auth.error}`);
  216. }
  217. headers[auth.headerName] = auth.headerValue;
  218. }
  219. const boundary = generateBoundary();
  220. headers['Content-Type'] = `multipart/form-data; boundary=${boundary}`;
  221. const body = buildMultipartBody(boundary, file, {
  222. processingMode: config.processingMode,
  223. batchSize: config.batchSize,
  224. targetWidth: config.targetWidth,
  225. groupId: config.groupId
  226. });
  227. smartbotic.log.info(`OCR Upload: ${file.filename} (${file.size} bytes)`);
  228. const response = smartbotic.http.request({
  229. method: 'POST',
  230. url: `${baseUrl}/api/ocr/upload`,
  231. headers: headers,
  232. body: body,
  233. timeout: 120000
  234. });
  235. if (response.status < 200 || response.status >= 300) {
  236. const errorMsg = typeof response.data === 'string' ? response.data : JSON.stringify(response.data);
  237. if (response.status === 401 && !config.credentialId) { throw new Error('Authentication required - please select a credential in node settings'); } throw new Error(`OCR API error (${response.status}): ${errorMsg}`);
  238. }
  239. const data = typeof response.data === 'string' ? JSON.parse(response.data) : response.data;
  240. return {
  241. job: data.job || data
  242. };
  243. }
  244. };