/* ============================================================ drive.jsx — Drive-based ingestion (Finance-first Step 1). Connect a Google Drive folder, list ALL its files (PDF/Excel/image), select the debit-note PDFs, and push them into the Lekha pipeline. Reads use the logged-in finance user's drive.readonly token (browser-side; no client secret). Backend only stores the folder + ingests uploaded PDFs. ============================================================ */ const DRIVE_SCOPE = "https://www.googleapis.com/auth/drive.readonly"; const FOLDER_MIME = "application/vnd.google-apps.folder"; // --- Drive access token (GIS token client) --------------------------------- // Persisted in sessionStorage so a PAGE REFRESH reuses the still-valid token (~1h) // and the documents load immediately — no popup, no click. sessionStorage is // per-tab and clears when the tab closes, so it's not stored long-term. Only when // the token is missing/expired do we request a new one (silent first; interactive // = a user click, which can pop the consent window). const _TOK_KEY = "lekha_drive_tok", _TOK_EXP = "lekha_drive_tok_exp"; let _driveTok = null, _driveTokExp = 0; try { _driveTok = sessionStorage.getItem(_TOK_KEY) || null; _driveTokExp = parseInt(sessionStorage.getItem(_TOK_EXP) || "0", 10) || 0; } catch (_) {} function _saveDriveTok(tok, expiresIn) { _driveTok = tok; _driveTokExp = Date.now() + ((expiresIn || 3000) - 60) * 1000; try { sessionStorage.setItem(_TOK_KEY, tok); sessionStorage.setItem(_TOK_EXP, String(_driveTokExp)); } catch (_) {} } // Forget the cached Drive token so the NEXT connect re-prompts (account chooser) — used // to switch to a different Google account. function clearDriveToken() { _driveTok = null; _driveTokExp = 0; try { sessionStorage.removeItem(_TOK_KEY); sessionStorage.removeItem(_TOK_EXP); } catch (_) {} } function getDriveToken(clientId, interactive) { if (_driveTok && Date.now() < _driveTokExp) return Promise.resolve(_driveTok); // reused across refresh // DESKTOP: Google blocks OAuth in the webview → native system-browser flow (Rust) that // returns a drive.readonly access token via the backend exchange. if (window.isDesktopApp && window.isDesktopApp()) { return window.desktopGoogleOAuth("openid email " + DRIVE_SCOPE).then((t) => { if (t && t.access_token) { _saveDriveTok(t.access_token, t.expires_in); return _driveTok; } throw new Error("no drive token"); }); } return new Promise((resolve, reject) => { const g = window.google && google.accounts && google.accounts.oauth2; if (!g) return reject(new Error("Google sign-in script not loaded yet")); const client = google.accounts.oauth2.initTokenClient({ client_id: clientId, scope: DRIVE_SCOPE, callback: (resp) => { if (resp && resp.access_token) { _saveDriveTok(resp.access_token, resp.expires_in); resolve(_driveTok); } else { reject(resp || new Error("no token")); } }, error_callback: (err) => reject(err || new Error("token error")), }); // interactive → show the account chooser so a different Google account can be picked client.requestAccessToken({ prompt: interactive ? "select_account" : "none" }); }); } // --- Drive REST calls (browser, Bearer token; googleapis supports CORS) ----- async function driveList(token, q, fields) { const url = "https://www.googleapis.com/drive/v3/files?q=" + encodeURIComponent(q) + "&fields=" + encodeURIComponent("files(" + fields + ")") + "&pageSize=200&orderBy=folder,name&supportsAllDrives=true&includeItemsFromAllDrives=true"; const r = await fetch(url, { headers: { Authorization: "Bearer " + token } }); if (!r.ok) throw new Error("Drive list failed (" + r.status + ")"); return (await r.json()).files || []; } const listFolders = (token, parent) => driveList(token, `'${parent}' in parents and mimeType='${FOLDER_MIME}' and trashed=false`, "id,name"); const listFiles = (token, folder) => driveList(token, `'${folder}' in parents and trashed=false`, "id,name,mimeType,modifiedTime"); async function driveDownload(token, fileId) { const r = await fetch("https://www.googleapis.com/drive/v3/files/" + fileId + "?alt=media&supportsAllDrives=true", { headers: { Authorization: "Bearer " + token } }); if (!r.ok) throw new Error("Download failed (" + r.status + ")"); return await r.blob(); } async function ingestToLekha(blob, fileId, fileName) { const fd = new FormData(); fd.append("file", new File([blob], fileName, { type: "application/pdf" })); fd.append("file_id", fileId); fd.append("file_name", fileName); const r = await fetch("/api/drive/ingest", { method: "POST", headers: { Authorization: "Bearer " + (localStorage.getItem("lekha_token") || "") }, body: fd, }); if (!r.ok) { let msg = "Import failed"; try { msg = (await r.json()).detail || msg; } catch (_) {} throw new Error(msg); } return await r.json(); } const isPdf = (f) => (f.mimeType === "application/pdf") || /\.pdf$/i.test(f.name || ""); function fileKind(f) { if (isPdf(f)) return "PDF"; const m = f.mimeType || ""; if (/sheet|excel|spreadsheet/i.test(m) || /\.(xlsx?|csv)$/i.test(f.name)) return "Excel"; if (/image\//.test(m) || /\.(png|jpe?g|gif|webp|tiff?)$/i.test(f.name)) return "Image"; return "Other"; } // --- folder browser (no Picker API needed; pure Drive REST) ---------------- function FolderBrowser({ clientId, onPick, onClose }) { const [stack, setStack] = React.useState([{ id: "root", name: "My Drive" }]); const [folders, setFolders] = React.useState([]); const [busy, setBusy] = React.useState(true); const [err, setErr] = React.useState(""); const here = stack[stack.length - 1]; const load = async (parent) => { setBusy(true); setErr(""); try { const tok = await getDriveToken(clientId, true); setFolders(await listFolders(tok, parent)); } catch (e) { setErr(e.message || "Could not read Drive"); } finally { setBusy(false); } }; React.useEffect(() => { load(here.id); }, [here.id]); return (
Choose your documents folder
{stack.map((s, i) => ( {i > 0 && / } setStack(stack.slice(0, i + 1))}>{s.name} ))}
{busy &&
Loading…
} {err &&
{err}
} {!busy && !err && folders.length === 0 &&
No sub-folders here.
} {folders.map((f) => ( ))}
); } // Human labels for Lekha's categories. `unclassified` ≠ `unrecognised`: // unrecognised = Gemini decided it's not a finance doc; unclassified = Gemini // didn't run (no key / failure) — surfaced so you can see the intelligence is off. const CAT_LABEL = { debit_note: "Debit note", credit_note: "Credit note", invoice: "Invoice", sales_register: "Sales register", bank_statement: "Bank statement", purchase_invoice: "Purchase invoice", unrecognised: "Unrecognised", unclassified: "Couldn’t read — retry", }; // Files are grouped by category; debit notes lead (they're the actionable ones). const GROUP_ORDER = ["debit_note", "credit_note", "invoice", "purchase_invoice", "sales_register", "bank_statement", "unrecognised", "unclassified", "__reading", "__other"]; const GROUP_TITLE = { debit_note: "Debit notes", credit_note: "Credit notes", invoice: "Invoices", purchase_invoice: "Purchase invoices", sales_register: "Sales registers", bank_statement: "Bank statements", unrecognised: "Unrecognised", unclassified: "Couldn’t read (retry)", __reading: "Reading…", __other: "Other files (view only)", }; // shared rounded-pill style for the row status badges const pill = (bg, fg, bd) => ({ display: "inline-flex", alignItems: "center", gap: 5, background: bg, color: fg, border: "1px solid " + bd, borderRadius: 999, padding: "3px 11px", fontSize: 11.5, fontWeight: 700, whiteSpace: "nowrap", }); async function classifyToLekha(blob, f, force) { const fd = new FormData(); fd.append("file", new File([blob], f.name, { type: "application/pdf" })); fd.append("file_id", f.id); fd.append("file_name", f.name); fd.append("modified_time", f.modifiedTime || ""); if (force) fd.append("force", "true"); const r = await fetch("/api/drive/classify", { method: "POST", headers: { Authorization: "Bearer " + (localStorage.getItem("lekha_token") || "") }, body: fd }); if (!r.ok) throw new Error("classify failed"); return await r.json(); // {category, ingestible, reason, retryable} } // --- main: connect state + file list + classify + select & import ---------- function DriveDocs({ who }) { const [clientId, setClientId] = React.useState(""); const [status, setStatus] = React.useState(null); const [files, setFiles] = React.useState(null); const [cats, setCats] = React.useState({}); // fileId -> {category, ingestible, reason} const [reading, setReading] = React.useState({}); // fileId -> true while classifying const [sel, setSel] = React.useState({}); const [browsing, setBrowsing] = React.useState(false); const [busy, setBusy] = React.useState(false); const [msg, setMsg] = React.useState(""); const [err, setErr] = React.useState(""); const [openGroups, setOpenGroups] = React.useState({ debit_note: true, invoice: true }); // ingestible groups open by default const [needsAuth, setNeedsAuth] = React.useState(false); // token gone after reload → one-click reconnect const refreshStatus = () => api.get("/api/drive/status").then((s) => { setStatus(s); setCats((c) => ({ ...(s.classifications || {}), ...c })); return s; }).catch(() => {}); React.useEffect(() => { api.get("/api/auth/config").then((c) => setClientId(c.google_client_id || "")).catch(() => {}); refreshStatus(); }, []); // Lekha's intelligence: download each not-yet-classified PDF and classify it // (sequential — gentle on the API). Cached server-side, so this is one-time. const classifyOne = async (token, f, force) => { setReading((r) => ({ ...r, [f.id]: true })); try { const blob = await driveDownload(token, f.id); const v = await classifyToLekha(blob, f, force); setCats((c) => ({ ...c, [f.id]: v })); } catch (_) { setCats((c) => ({ ...c, [f.id]: { category: "unclassified", ingestible: false, reason: "could not read", retryable: true } })); } finally { setReading((r) => { const n = { ...r }; delete n[f.id]; return n; }); } }; // Read-once: classify ONLY files we haven't seen (new id) or that changed // (new modifiedTime). Everything already classified is served from cache. const needsRead = (f, known) => { if (!isPdf(f)) return false; const k = known[f.id]; return !k || (k.modified_time || "") !== (f.modifiedTime || ""); }; const loadFiles = async (st, interactive) => { if (!st || !st.connected || !clientId) return; setBusy(true); setErr(""); let tok; try { tok = await getDriveToken(clientId, interactive); } catch (_) { setBusy(false); setNeedsAuth(true); // token needs a user click return; } setNeedsAuth(false); try { const list = await listFiles(tok, st.folder_id); setFiles(list); const known = st.classifications || {}; for (const f of list) { // only the new / changed ones if (needsRead(f, known)) await classifyOne(tok, f); } } catch (e) { setErr(e.message || "Could not read folder"); } finally { setBusy(false); } }; const retryOne = async (f) => { // force re-read one file if (!clientId) return; try { await classifyOne(await getDriveToken(clientId, true), f, true); } catch (e) { setErr(e.message || "Retry failed"); } }; // On load, try a SILENT token; if it needs interaction we show a button. React.useEffect(() => { if (status && status.connected && clientId) loadFiles(status, false); }, [status && status.folder_id, clientId]); const pickFolder = async (folder) => { setBrowsing(false); setBusy(true); setErr(""); setMsg(""); try { const st = await api.req("PUT", "/api/drive/folder", { folder_id: folder.id, folder_name: folder.name }); setFiles(null); setSel({}); await refreshStatus(); } catch (e) { setErr(e.message || "Could not save folder"); } finally { setBusy(false); } }; const isIngestible = (f) => cats[f.id] && cats[f.id].ingestible; // debit notes + sales invoices const importSelected = async () => { const chosen = (files || []).filter((f) => sel[f.id] && isIngestible(f)); if (!chosen.length) return; setBusy(true); setErr(""); setMsg(""); let ok = 0; try { const tok = await getDriveToken(clientId, true); for (const f of chosen) { const blob = await driveDownload(tok, f.id); await ingestToLekha(blob, f.id, f.name); ok++; } setMsg(`Imported ${ok} document${ok === 1 ? "" : "s"} — now with Lekha for review. If any issues or discrepancies, Lekha will reach out here.`); setSel({}); await refreshStatus(); } catch (e) { setErr(`Imported ${ok}. ${e.message || "Import stopped."}`); } finally { setBusy(false); } }; // --- not connected: single center-aligned CTA --- if (status && !status.connected) { return ( <> {browsing && setBrowsing(false)} />}
Connect your Drive
Pick the Google Drive folder that holds your documents. Lekha will list them here so you can send your debit notes & sales invoices through for review.
{!clientId &&
Google connection isn’t configured.
} {err &&
{err}
}
); } // --- connected: folder header + grouped, collapsible file list --- const list = files || []; const ingested = new Set((status && status.ingested_file_ids) || []); const selectableCount = list.filter((f) => sel[f.id] && isIngestible(f) && !ingested.has(f.id)).length; const stillReading = Object.keys(reading).length; const groupOf = (f) => { if (!isPdf(f)) return "__other"; if (reading[f.id] || (!cats[f.id] && !ingested.has(f.id))) return "__reading"; return (cats[f.id] && cats[f.id].category) || "__reading"; }; const groups = {}; for (const f of list) (groups[groupOf(f)] ||= []).push(f); const shownGroups = GROUP_ORDER.filter((g) => groups[g] && groups[g].length); const readyCount = list.filter((f) => isIngestible(f) && !ingested.has(f.id)).length; const toggle = (g) => setOpenGroups((o) => ({ ...o, [g]: !o[g] })); const renderRow = (f) => { const pdf = isPdf(f), done = ingested.has(f.id), cat = cats[f.id]; const isReading = !!reading[f.id] || (pdf && !cat && !done); const ing = pdf && ((cat && cat.ingestible) || done); const selectable = ing && !done && !isReading; let badge; if (done) badge = Imported; else if (isReading) badge = Reading…; else if (pdf && cat && cat.category === "unclassified") badge = ; else if (selectable) badge = Ready; else badge = View only; return ( ); }; return ( <> {browsing && setBrowsing(false)} />}
Documents from Drive
Folder: {(status && status.folder_name) || "—"} · Lekha reads each file; tap a section to open it.
{/* token gone after page reload → one click to view (no folder re-pick) */} {needsAuth && (
Show your documents
Reconnect to Google Drive to load {(status && status.folder_name) || "your folder"} — your folder choice is saved.
)} {/* at-a-glance summary */}
{readyCount}
ready to import
{list.length}
files in folder
{stillReading > 0 &&
{stillReading}
Lekha is reading…
}
{msg &&
{msg}
} {err &&
{err}
} {busy && !files &&
Loading folder…
} {files && files.length === 0 &&
This folder is empty.
} {/* collapsible category sections — closed by default (debit notes open) */}
{shownGroups.map((g) => { const open = !!openGroups[g], isDN = g === "debit_note" || g === "invoice"; // ingestible groups return (
{open &&
{groups[g].map(renderRow)}
}
); })}
); } Object.assign(window, { DriveDocs });