// ai-extract.jsx — reads an attached invoice ON THIS COMPUTER and guesses the fields. // Uses pdf.js for digital PDFs (fast, accurate) and Tesseract OCR for photos/scans. // Nothing is sent anywhere — the reader downloads once, then the file is read locally. function havenLoadScript(src, globalKey) { if (window[globalKey]) return Promise.resolve(); if (!window.__havenScriptPromises) window.__havenScriptPromises = {}; if (!window.__havenScriptPromises[src]) { window.__havenScriptPromises[src] = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = src; s.onload = resolve; s.onerror = () => { delete window.__havenScriptPromises[src]; reject(new Error('load failed: ' + src)); }; document.head.appendChild(s); }); } return window.__havenScriptPromises[src]; } async function havenOcrImage(src) { await havenLoadScript('https://unpkg.com/tesseract.js@5.1.1/dist/tesseract.min.js', 'Tesseract'); const { data } = await window.Tesseract.recognize(src, 'eng'); return (data && data.text) || ''; } async function havenExtractText(file) { const isPdf = /pdf/i.test(file.type) || /\.pdf$/i.test(file.name); if (isPdf) { await havenLoadScript('https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.min.js', 'pdfjsLib'); window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.worker.min.js'; const buf = await file.arrayBuffer(); const pdf = await window.pdfjsLib.getDocument({ data: buf }).promise; let text = ''; const pages = Math.min(pdf.numPages, 2); for (let i = 1; i <= pages; i++) { const page = await pdf.getPage(i); const tc = await page.getTextContent(); text += tc.items.map((it) => it.str).join('\n') + '\n'; } if (text.replace(/\s/g, '').length >= 40) return text; // digital PDF — done // scanned PDF → draw page 1 and OCR it const page = await pdf.getPage(1); const vp = page.getViewport({ scale: 2 }); const canvas = document.createElement('canvas'); canvas.width = vp.width; canvas.height = vp.height; await page.render({ canvasContext: canvas.getContext('2d'), viewport: vp }).promise; return await havenOcrImage(canvas); } return await havenOcrImage(file); // photo / image } function havenParseInvoice(text) { const t = String(text || '').replace(/\r/g, ''); const lower = t.toLowerCase(); const out = {}; // provider — best evidence is one of YOUR known providers named in the file const prov = Store.doc.providers.find((p) => (p.name && p.name.length > 4 && lower.includes(p.name.toLowerCase())) || (p.short && p.short.length > 4 && lower.includes(p.short.toLowerCase()))); if (prov) { out.provider = prov.name; out.cat = prov.cat; } else { const firstLine = t.split('\n').map((s) => s.trim()).find((s) => s.length > 3 && s.length < 60 && !/tax invoice|invoice|receipt|abn|gst|statement/i.test(s)); if (firstLine) out.provider = firstLine; } // amount — prefer a "total" line, else the largest dollar figure let m = t.match(/total[^\n$]{0,30}\$?\s*([\d,]+\.\d{2})/i); if (!m) { const all = [...t.matchAll(/\$\s*([\d,]+\.\d{2})/g)].map((x) => parseFloat(x[1].replace(/,/g, ''))); if (all.length) m = [null, String(Math.max.apply(null, all))]; } if (m) { const v = parseFloat(String(m[1]).replace(/,/g, '')); if (v > 0 && v < 100000) out.amount = v; } // invoice / receipt number const inv = t.match(/(?:invoice|receipt|inv)\s*(?:no\.?|number|#)?\s*[:\-]?\s*([A-Z]{0,5}[-]?\d{2,}[A-Z0-9-]*)/i); if (inv) out.id = inv[1].trim().slice(0, 20); // date — 10/06/2026 or 10 June 2026 styles const mNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec']; const d1 = t.match(/(\d{1,2})[\/\-](\d{1,2})[\/\-](20\d{2})/); const d2 = t.match(/(\d{1,2})(?:st|nd|rd|th)?\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(20\d{2})/i); if (d1) out.date = d1[3] + '-' + String(d1[2]).padStart(2, '0') + '-' + String(d1[1]).padStart(2, '0'); else if (d2) { const mi = mNames.indexOf(d2[2].toLowerCase()); if (mi >= 0) out.date = d2[3] + '-' + String(mi + 1).padStart(2, '0') + '-' + String(d2[1]).padStart(2, '0'); } return out; } // returns { provider?, cat?, amount?, id?, date? } or null if unreadable async function extractInvoiceFields(file) { try { const text = await havenExtractText(file); if (!text || text.trim().length < 10) return null; const out = havenParseInvoice(text); return Object.keys(out).length ? out : null; } catch (e) { return null; } } Object.assign(window, { extractInvoiceFields });