/* eslint-disable */ /* © 2026 K2B NETWORK SOLUTIONS LLC. All rights reserved. Proprietary and confidential. Unauthorized copying, distribution, or use of this file, via any medium, is strictly prohibited. Original work product. */ // OldReports — read a packet of scanned / photographed old-DMS printouts and prepare it for import. // // 1. window.OldReports (the reading engine; no React) // readFile(file, { onProgress, rotate }) -> { id, name, kind, pages:[Page] } pdf.js / image -> pages (nothing is sent anywhere) // readPages(pages, { concurrency, signal, onPage, onEvent, hint, force }) -> { read, failed, stopped, aborted, skippedDone } // plainError(code, status, serverMessage) -> plain-English sentence // session the packet in memory (files, pages, results, decisions, imported steps) // 2. OldReportsScreen the "Old reports" screen of the Upload & Import tool (drop zone, page thumbnails, cost estimate, // progress, "Found in this packet" cards with tie-out checks, import order guide, reference pages, problems). // It never commits anything: "Review & import" hands a dataset to the EXISTING import wizard (UploadTool.jsx.txt). // // Pages are rendered here in the browser (pdf.js from the vendored loader, JPEG <= 2000 px / <= 1.2 MB) and sent one at a time to // POST /api/ai-import/read-page. The Anthropic key lives only on the server. Results are kept in this browser tab's memory only // (they contain customer names): a reload forgets them, so download the CSVs if you want to keep a copy. // All top-level names in this file start with "or" / "Or" (every *.jsx.txt shares one global scope). (function () { "use strict"; const CFG = { maxSide: 2000, // longest side of the JPEG sent to the reader (px) maxBytes: 1200000, // target size of one page (bytes); the server limit is 5 MiB thumbW: 170, // thumbnail width (px) concurrency: 3, // pages read at the same time retries: 2, // automatic retries of a page after 429 / 5xx / network trouble patientRetries: 8, // ... when the 429 says how long to wait (the server's per-minute limit) backoffMs: [2000, 5000], // wait before retry 1 and 2 (the server's Retry-After wins when it is longer) maxWaitMs: 30000, // never wait longer than this between retries timeoutMs: 420000, // one page may take minutes on a dense scan blank: { ink: 0.004, contrast: 40, measureW: 640, margin: 0.05 }, // page is blank when fewer than 0.4 % of the pixels inside its margins are clearly darker than the paper maxFileBytes: 200 * 1024 * 1024, }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const uid = (() => { let n = 0; return (p) => (p || "id") + (++n) + "-" + Math.random().toString(36).slice(2, 6); })(); const mk = (w, h) => { const c = document.createElement("canvas"); c.width = Math.max(1, Math.round(w)); c.height = Math.max(1, Math.round(h)); return c; }; const toBlob = (canvas, q) => new Promise((res, rej) => canvas.toBlob((b) => (b ? res(b) : rej(new Error("The browser could not make a picture of this page."))), "image/jpeg", q)); const toB64 = (blob) => new Promise((res, rej) => { const r = new FileReader(); r.onload = () => { const s = String(r.result); res(s.slice(s.indexOf(",") + 1)); }; r.onerror = () => rej(r.error); r.readAsDataURL(blob); }); const release = (c) => { try { c.width = 0; c.height = 0; } catch (e) {} }; // Fraction of "ink" pixels in the middle of the page: pixels clearly darker than the paper (paper = the 85th percentile brightness). // The outer 5 % is ignored (scanners leave black edge bars and punch-hole shadows there). const inkOf = (src) => { const W = CFG.blank.measureW, H = Math.max(1, Math.round(src.height * W / src.width)); const c = mk(W, H), g = c.getContext("2d", { willReadFrequently: true }); g.fillStyle = "#fff"; g.fillRect(0, 0, W, H); g.drawImage(src, 0, 0, W, H); const d = g.getImageData(0, 0, W, H).data, hist = new Uint32Array(256); const x0 = Math.floor(W * CFG.blank.margin), x1 = W - x0, y0 = Math.floor(H * CFG.blank.margin), y1 = H - y0; const n = Math.max(1, (x1 - x0) * (y1 - y0)), lum = new Uint8Array(n); let i = 0; for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++, i++) { const j = (y * W + x) * 4; const v = (d[j] * 299 + d[j + 1] * 587 + d[j + 2] * 114) / 1000 | 0; lum[i] = v; hist[v]++; } let acc = 0, paper = 255; const target = n * 0.85; for (let v = 0; v < 256; v++) { acc += hist[v]; if (acc >= target) { paper = v; break; } } const cut = paper - CFG.blank.contrast; let ink = 0; for (let k = 0; k < n; k++) if (lum[k] < cut) ink++; release(c); return { ink: ink / n, paper }; }; const rotated = (src, deg) => { deg = ((deg % 360) + 360) % 360; if (!deg) return src; const swap = deg === 90 || deg === 270; const c = mk(swap ? src.height : src.width, swap ? src.width : src.height), g = c.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, c.width, c.height); g.translate(c.width / 2, c.height / 2); g.rotate(deg * Math.PI / 180); g.drawImage(src, -src.width / 2, -src.height / 2); return c; }; // canvas -> JPEG that fits maxBytes (quality steps first, then a smaller picture) const fitJpeg = async (canvas, maxBytes) => { let c = canvas, q = 0.86, blob = await toBlob(c, q), guard = 0; while (blob.size > maxBytes && guard++ < 8) { if (q > 0.56) q -= 0.1; else { const n = mk(c.width * 0.85, c.height * 0.85); const g = n.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, n.width, n.height); g.drawImage(c, 0, 0, n.width, n.height); if (c !== canvas) release(c); c = n; } blob = await toBlob(c, q); } const out = { blob, width: c.width, height: c.height, bytes: blob.size }; if (c !== canvas) release(c); return out; }; const pdfErr = (e, name) => { const n = (e && e.name) || "", m = String((e && e.message) || e || ""); if (n === "PasswordException" || /password/i.test(m)) return new Error(name + " is password-protected. Open it, save an unprotected copy (or print to PDF) and add that."); if (n === "InvalidPDFException" || /Invalid PDF|not a PDF|Missing PDF/i.test(m)) return new Error(name + " does not look like a valid PDF. Scan or save it again and add the new file."); return new Error("Could not open " + name + ": " + m); }; /* ───────────────────────── reading a file into pages ───────────────────────── */ const isPdf = (f) => /\.pdf$/i.test(f.name || "") || f.type === "application/pdf"; const isImg = (f) => /\.(png|jpe?g)$/i.test(f.name || "") || /^image\/(png|jpeg)$/.test(f.type || ""); async function readFile(file, opts) { opts = opts || {}; const prog = (done, total, label) => { try { opts.onProgress && opts.onProgress({ done, total, label }); } catch (e) {} }; const rot0 = (i) => { const r = typeof opts.rotate === "function" ? opts.rotate(i) : opts.rotate; return ((Math.round((+r || 0) / 90) * 90) % 360 + 360) % 360; }; if (!file) throw new Error("No file."); if (file.size > CFG.maxFileBytes) throw new Error(file.name + " is larger than " + Math.round(CFG.maxFileBytes / 1048576) + " MB. Split it into smaller files."); const fid = uid("f"); if (isPdf(file)) { if (!window.JBImporters || !window.JBImporters.loadPdfJs) throw new Error("The PDF reader is not available in this window (Importers.jsx.txt did not load). Reload the page and try again."); let pdfjs; try { pdfjs = await window.JBImporters.loadPdfJs(); } catch (e) { throw new Error("The PDF reader could not be loaded: " + ((e && e.message) || e) + " Check that /ui_kits/k2b-dms/vendor/ is served, then reload the page."); } let doc; try { doc = await pdfjs.getDocument({ data: new Uint8Array(await file.arrayBuffer()) }).promise; } catch (e) { throw pdfErr(e, file.name); } const total = doc.numPages, pages = []; for (let i = 1; i <= total; i++) { prog(i - 1, total, "Opening " + file.name + " — page " + i + " of " + total); let pg; try { pg = await doc.getPage(i); } catch (e) { throw pdfErr(e, file.name); } const v1 = pg.getViewport({ scale: 1 }); const draw = async (maxSide) => { const sc = maxSide / Math.max(v1.width, v1.height), vp = pg.getViewport({ scale: sc }); const c = mk(vp.width, vp.height), g = c.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, c.width, c.height); await pg.render({ canvasContext: g, viewport: vp }).promise; return c; }; const m = await draw(CFG.blank.measureW * 1.5); // small render: thumbnail + blank test const ink = inkOf(m); const th = mk(CFG.thumbW, m.height * CFG.thumbW / m.width), tg = th.getContext("2d"); tg.fillStyle = "#fff"; tg.fillRect(0, 0, th.width, th.height); tg.drawImage(m, 0, 0, th.width, th.height); const thumb = th.toDataURL("image/jpeg", 0.72); release(th); release(m); pages.push({ id: fid + ":p" + i, fileId: fid, fileName: file.name, pageNo: i, totalPages: total, kind: "pdf", w: Math.round(v1.width), h: Math.round(v1.height), thumb, ink: ink.ink, blank: ink.ink < CFG.blank.ink, rotate: rot0(i - 1), excluded: false, forceSend: false, status: "waiting", error: "", code: "", result: null, reads: 0, render: async (rot, shrink) => { const base = await draw(CFG.maxSide * (shrink ? Math.pow(0.7, shrink) : 1)); const r = rotated(base, rot); const j = await fitJpeg(r, CFG.maxBytes * (shrink ? Math.pow(0.6, shrink) : 1)); if (r !== base) release(r); release(base); j.base64 = await toB64(j.blob); j.mediaType = "image/jpeg"; delete j.blob; return j; } }); try { pg.cleanup && pg.cleanup(); } catch (e) {} } prog(total, total, "Opened " + file.name); return { id: fid, name: file.name, size: file.size, kind: "pdf", pages, destroy: () => { try { doc.destroy(); } catch (e) {} } }; } if (isImg(file)) { prog(0, 1, "Opening " + file.name); let bmp; try { bmp = await createImageBitmap(file, { imageOrientation: "from-image" }); } catch (e) { throw new Error("Could not open " + file.name + " as a picture. Use a PNG or JPG file (HEIC photos: share or export them as JPG first)."); } const draw = (maxSide) => { const sc = Math.min(1, maxSide / Math.max(bmp.width, bmp.height)); const c = mk(bmp.width * sc, bmp.height * sc), g = c.getContext("2d"); g.fillStyle = "#fff"; g.fillRect(0, 0, c.width, c.height); g.drawImage(bmp, 0, 0, c.width, c.height); return c; }; const m = draw(CFG.blank.measureW * 1.5), ink = inkOf(m); const th = mk(CFG.thumbW, m.height * CFG.thumbW / m.width), tg = th.getContext("2d"); tg.fillStyle = "#fff"; tg.fillRect(0, 0, th.width, th.height); tg.drawImage(m, 0, 0, th.width, th.height); const thumb = th.toDataURL("image/jpeg", 0.72); release(th); release(m); const page = { id: fid + ":p1", fileId: fid, fileName: file.name, pageNo: 1, totalPages: 1, kind: "image", w: bmp.width, h: bmp.height, thumb, ink: ink.ink, blank: ink.ink < CFG.blank.ink, rotate: rot0(0), excluded: false, forceSend: false, status: "waiting", error: "", code: "", result: null, reads: 0, render: async (rot, shrink) => { const base = draw(CFG.maxSide * (shrink ? Math.pow(0.7, shrink) : 1)); const r = rotated(base, rot); const j = await fitJpeg(r, CFG.maxBytes * (shrink ? Math.pow(0.6, shrink) : 1)); if (r !== base) release(r); release(base); j.base64 = await toB64(j.blob); j.mediaType = "image/jpeg"; delete j.blob; return j; } }; prog(1, 1, "Opened " + file.name); return { id: fid, name: file.name, size: file.size, kind: "image", pages: [page], destroy: () => { try { bmp.close && bmp.close(); } catch (e) {} } }; } throw new Error(file.name + " is not a PDF, PNG or JPG file. (Spreadsheets and CSV files go through the normal Import steps.)"); } /* ───────────────────────── talking to the server ───────────────────────── */ // fatal = stop the whole run (the cause is not this page); auto = retry the page by itself; page = this page failed, retry it alone const CODES = { not_configured: { fatal: true, title: "AI reading is not set up", next: "An administrator needs to open AI Import settings, paste the Anthropic key and press Test connection." }, no_consent: { fatal: true, title: "AI reading has no consent yet", next: "An administrator must tick the consent box in AI Import settings." }, forbidden: { fatal: true, title: "Your role cannot use AI reading", next: "Ask an Administrator, Office Manager or Accounting user to read these pages." }, auth_failed: { fatal: true, title: "Anthropic rejected the key", next: "An administrator should copy the key again from console.anthropic.com > API keys into AI Import settings and press Test connection." }, no_credit: { fatal: true, title: "The Anthropic account has no credit left", next: "Add credit at console.anthropic.com > Plans & Billing, then press Read again - pages already read are kept." }, model_unavailable: { fatal: true, title: "This key cannot use the chosen model", next: "An administrator should pick another model in AI Import settings and press Test connection." }, daily_limit: { fatal: true, title: "The daily page limit is reached", next: "It resets at midnight, or an administrator can raise the limit in AI Import settings." }, session: { fatal: true, title: "Your sign-in expired", next: "Sign in again - pages already read stay in this window." }, rate_limited: { auto: true, title: "Sending pages too fast", next: "K2B waits and continues by itself." }, upstream: { auto: true, title: "The reading service is busy or unreachable", next: "K2B retries by itself; if it keeps failing, retry the page in a few minutes." }, unreadable: { auto: true, title: "The answer could not be understood", next: "Retry the page; if it keeps failing, scan it again more clearly (straight, dark, full page)." }, refused: { page: true, title: "Claude declined to read this page", next: "Retry the page once; if it keeps happening, enter that page by hand or ask an administrator to pick a different model." }, truncated: { page: true, title: "The page is too dense to read in one go", next: "Scan or photograph it in two halves (top and bottom) and add those, or retry once." }, too_large: { page: true, title: "The page picture is too large", next: "K2B shrinks it and tries again by itself." }, bad_request: { page: true, title: "The server could not accept this page", next: "Retry the page." }, network: { auto: true, title: "Could not reach the server", next: "Check the network connection; K2B retries by itself." }, timeout: { auto: true, title: "The server took too long", next: "K2B retries by itself." }, }; const goodSentence = (s) => typeof s === "string" && s.length >= 20 && !/[<>]/.test(s) && /\s/.test(s); function plainError(code, status, msg) { if (goodSentence(msg)) return msg; const c = CODES[code]; if (c) return c.title + ". " + c.next; if (status === 413) return "The page picture is too large for the server. K2B shrinks it and tries again."; if (status === 502 || status === 503 || status === 504) return "The server is busy or restarting. Wait a minute and retry the page."; if (status === 401) return CODES.session.title + ". " + CODES.session.next; if (status === 403) return CODES.forbidden.title + ". " + CODES.forbidden.next; return "Something unexpected happened while reading the page (" + (status || "no answer") + "). Retry the page; if it keeps happening, tell your administrator."; } // POST helper with an abort signal; same headers as apiFetch (session token + station id), never throws for HTTP errors async function post(path, body, signal) { const base = typeof API_BASE !== "undefined" ? API_BASE : location.origin + "/api"; const tok = typeof AUTH_TOKEN !== "undefined" ? AUTH_TOKEN : null; const headers = { "Content-Type": "application/json" }; if (tok) headers["Authorization"] = "Bearer " + tok; try { if (typeof __sessionId !== "undefined" && __sessionId) headers["X-Session-Id"] = __sessionId; } catch (e) {} try { if (tok && window.K2BStore) window.K2BStore.stamp(headers); } catch (e) {} // RC35 (brd): the page's store + protocol (SPEC 4.1) const res = await fetch(base + path, { method: "POST", headers, body: JSON.stringify(body), credentials: "omit", signal }); let text = "", json = null; try { text = await res.text(); json = JSON.parse(text); } catch (e) {} if (res.status === 401 && tok) { try { if (typeof setAuthToken === "function") setAuthToken(null); if (typeof setConnState === "function") setConnState("auth-expired"); } catch (e) {} } return { status: res.status, ok: res.ok, json, retryAfter: parseFloat(res.headers.get("Retry-After")) || 0 }; } // The server limits how many pages one person may send a minute (30 by default). The first 429 teaches the screen how many pages got // through in the last minute; from then on it paces itself under that number, so a long packet does not keep running into the limit. const pace = { limit: 0, stamps: [] }; const paceRecent = () => { const now = Date.now(); pace.stamps = pace.stamps.filter((t) => now - t < 60000); if (pace.limit && now - (pace.learnedAt || 0) > 15 * 60000) pace.limit = 0; return pace.stamps; }; // a limit that was not met for 15 minutes is forgotten async function paceWait(signal) { for (;;) { if (signal && signal.aborted) return; const r = paceRecent(); if (!pace.limit || r.length < pace.limit) return; await sleep(Math.min(CFG.maxWaitMs, Math.max(100, r[0] + 60000 - Date.now() + 50))); } } // Read the pages that are waiting (never a page that is already read, unless opts.force). Resolves when all are done or the run stopped. async function readPages(pages, opts) { const o = Object.assign({ concurrency: CFG.concurrency, signal: null, onPage: null, onEvent: null, hint: "", force: false }, opts || {}); const queue = (pages || []).filter((p) => o.force || p.status !== "done"); const skippedDone = (pages || []).length - queue.length; const ctl = new AbortController(); if (o.signal) { if (o.signal.aborted) ctl.abort(); else o.signal.addEventListener("abort", () => ctl.abort()); } const out = { read: 0, failed: 0, stopped: null, aborted: false, skippedDone }; let next = 0, pauseUntil = 0; const emit = (page, ev) => { try { o.onPage && o.onPage(page, ev); } catch (e) {} }; const note = (ev) => { try { o.onEvent && o.onEvent(ev); } catch (e) {} }; async function one(page) { page.status = "reading"; page.error = ""; page.code = ""; page.retryable = false; page.attempt = 0; emit(page, "start"); let attempt = 0, shrink = 0; for (;;) { if (ctl.signal.aborted) { page.status = "waiting"; emit(page, "aborted"); return; } const wait = pauseUntil - Date.now(); if (wait > 0) { await sleep(Math.min(wait, CFG.maxWaitMs)); continue; } await paceWait(ctl.signal); if (ctl.signal.aborted) continue; let rendered; try { rendered = await page.render(page.rotate, shrink); } catch (e) { page.status = "failed"; page.code = "render"; page.error = "K2B could not turn this page into a picture: " + ((e && e.message) || e) + " Remove the page and add it again."; out.failed++; emit(page, "failed"); return; } const body = { image: { mediaType: rendered.mediaType, data: rendered.base64 }, fileName: page.fileName, pageNo: page.pageNo, totalPages: page.totalPages }; if (o.hint) body.hint = o.hint; page.sentBytes = rendered.bytes; page.sentW = rendered.width; page.sentH = rendered.height; const stamp = Date.now(); pace.stamps.push(stamp); const rc = new AbortController(); const onAbort = () => rc.abort(); ctl.signal.addEventListener("abort", onAbort); const to = setTimeout(() => { page._timedOut = true; rc.abort(); }, CFG.timeoutMs); let r; try { r = await post("/ai-import/read-page", body, rc.signal); } catch (e) { if (ctl.signal.aborted) { clearTimeout(to); page.status = "waiting"; emit(page, "aborted"); return; } r = { status: 0, ok: false, json: null, retryAfter: 0, netErr: page._timedOut ? "timeout" : "network" }; page._timedOut = false; } clearTimeout(to); ctl.signal.removeEventListener("abort", onAbort); const j = r.json || {}; if (r.status === 429 || r.netErr) { const i = pace.stamps.indexOf(stamp); if (i >= 0) pace.stamps.splice(i, 1); } // that request was not accepted // only the K2B server's own per-minute limit teaches the pace (an upstream "Anthropic is slowing this account down" says nothing about it) if (r.status === 429 && j.code === "rate_limited" && /faster than allowed/i.test(j.error || "")) { const got = paceRecent().length; if (got >= 4) { const lim = Math.max(3, got - 1); pace.limit = pace.limit ? Math.min(pace.limit, lim) : lim; pace.learnedAt = Date.now(); } } if (r.ok && j.ok) { page.status = "done"; page.result = j; page.reads++; page.error = ""; out.read++; emit(page, "done"); return; } let code = j.code || r.netErr || (r.status === 401 ? "session" : r.status === 413 ? "too_large" : r.status === 429 ? "rate_limited" : r.status >= 500 || !r.status ? "upstream" : r.status === 403 ? "forbidden" : "bad_request"); const meta = CODES[code] || {}; // the picture was refused as too big / unusable: shrink it and try once or twice more if ((code === "too_large" || (code === "bad_request" && r.status === 422)) && shrink < 2) { shrink++; continue; } // a fatal cause is not this page's fault: stop everything, keep what was read, leave this page waiting if (meta.fatal) { out.stopped = { code, status: r.status, error: plainError(code, r.status, j.error), page: { fileName: page.fileName, pageNo: page.pageNo } }; page.status = "waiting"; page.error = ""; emit(page, "stopped"); note({ type: "stopped", stopped: out.stopped }); ctl.abort(); return; } const retryable = j.retryable !== false && (meta.auto || (r.status === 429 || r.status >= 500 || !r.status)); // a 429 that says how long to wait (the server's own per-minute limit, or Anthropic's Retry-After) is waited out patiently; // anything else is retried twice and then handed to the person const patient = code === "rate_limited" && (+j.retryAfterSec > 0 || r.retryAfter > 0); if (retryable && attempt < (patient ? CFG.patientRetries : CFG.retries) && code !== "daily_limit") { const wait2 = Math.min(CFG.maxWaitMs, Math.max(CFG.backoffMs[Math.min(attempt, CFG.backoffMs.length - 1)], (j.retryAfterSec || r.retryAfter || 0) * 1000)); attempt++; page.attempt = attempt; if (code === "rate_limited") pauseUntil = Math.max(pauseUntil, Date.now() + wait2); note({ type: "retry", code, attempt, waitMs: wait2, page: { fileName: page.fileName, pageNo: page.pageNo } }); emit(page, "retry"); await sleep(wait2); continue; } page.status = "failed"; page.code = code; page.retryable = j.retryable !== false; page.error = plainError(code, r.status, j.error); out.failed++; emit(page, "failed"); return; } } const worker = async () => { while (!ctl.signal.aborted) { const p = queue[next++]; if (!p) return; await one(p); } }; await Promise.all(Array.from({ length: Math.max(1, Math.min(o.concurrency, queue.length)) }, worker)); out.aborted = ctl.signal.aborted && !out.stopped; queue.forEach((p) => { if (p.status === "reading") p.status = "waiting"; }); return out; } window.OldReports = { version: "1.0", config: CFG, readFile, readPages, plainError, CODES, isPdf, isImg, inkOf, pace, _post: post }; })(); /* ───────────────────────── the packet in memory (survives switching between Old reports / Import / History) ───────────────────────── */ const orSession = (() => { const S = { files: [], decisions: {}, imported: {}, adding: null, addErrors: [], run: null, notice: null, rev: 0, rrev: 0 }; // rev = anything changed (re-render); rrev = the read results / decisions changed (rebuild the datasets) const subs = new Set(); let packetCache = null, packetRev = -1; const emit = () => { S.rev++; subs.forEach((f) => { try { f(S); } catch (e) {} }); }; const OR = () => window.OldReports; const allPages = () => S.files.reduce((a, f) => a.concat(f.pages), []); const pageById = (id) => allPages().find((p) => p.id === id); const usable = (p) => !p.excluded && (!p.blank || p.forceSend); // pages the Read button would send now const toRead = () => allPages().filter((p) => usable(p) && p.status === "waiting"); const failedPages = () => allPages().filter((p) => p.status === "failed"); const resultsList = () => { const out = []; allPages().forEach((p) => { if (p.status === "done" && p.result) out.push(Object.assign({}, p.result, { fileName: p.fileName, pageNo: p.pageNo, ok: true })); else if (p.status === "failed") out.push({ fileName: p.fileName, pageNo: p.pageNo, ok: false, error: p.error, code: p.code, retryable: p.retryable !== false }); }); return out; }; // the converter output for everything read so far (recomputed when a page result or a fix changes) let packetAt = 0; const packet = () => { if (packetCache && packetRev === S.rrev) return packetCache; // while pages are coming in, the checks are recomputed at most every 2.5 s (the build is the heavy part; the end of the run always rebuilds) if (packetCache && S.run && S.run.active && Date.now() - packetAt < 2500) return packetCache; const O = window.OldReportsData; let pk = null; try { pk = O && O.buildDatasets ? O.buildDatasets(resultsList(), { decisions: S.decisions }) : null; } catch (e) { pk = null; console.error("OldReportsData.buildDatasets failed", e); } packetCache = pk; packetRev = S.rrev; packetAt = Date.now(); return pk; }; const cost = () => { let usd = 0, inTok = 0, outTok = 0; allPages().forEach((p) => { if (p.result && p.result.usage) { usd += +p.result.usage.costUsd || 0; inTok += +p.result.usage.inputTokens || 0; outTok += +p.result.usage.outputTokens || 0; } }); return { usd, inTok, outTok }; }; const api = { get: () => S, packet, cost, allPages, pageById, toRead, failedPages, usable, resultsList, subscribe: (fn) => { subs.add(fn); return () => subs.delete(fn); }, // add files (the same file twice is ignored); each file is opened page by page — nothing is sent anywhere yet async addFiles(list) { const files = Array.from(list || []); if (!files.length) return; S.addErrors = []; for (const f of files) { if (S.files.some((x) => x.name === f.name && x.size === f.size)) { S.addErrors.push({ name: f.name, text: f.name + " is already in this packet." }); emit(); continue; } S.adding = { name: f.name, done: 0, total: 0, label: "Opening " + f.name + "…" }; emit(); try { const rf = await OR().readFile(f, { onProgress: (p) => { S.adding = { name: f.name, done: p.done, total: p.total, label: p.label }; emit(); } }); rf.lastModified = f.lastModified; S.files.push(rf); } catch (e) { S.addErrors.push({ name: f.name, text: (e && e.message) || String(e) }); } } S.adding = null; emit(); }, removeFile(id) { const f = S.files.find((x) => x.id === id); if (!f) return; try { f.destroy && f.destroy(); } catch (e) {} S.files = S.files.filter((x) => x.id !== id); S.rrev++; emit(); }, rotate(pageId, delta) { const p = pageById(pageId); if (!p) return; p.rotate = (((p.rotate + delta) % 360) + 360) % 360; emit(); }, rotateFile(fileId, delta) { const f = S.files.find((x) => x.id === fileId); if (!f) return; f.pages.forEach((p) => { p.rotate = (((p.rotate + delta) % 360) + 360) % 360; }); emit(); }, rotateAll(delta) { allPages().forEach((p) => { p.rotate = (((p.rotate + delta) % 360) + 360) % 360; }); emit(); }, toggleExclude(pageId) { const p = pageById(pageId); if (!p) return; p.excluded = !p.excluded; emit(); }, sendAnyway(pageId) { const p = pageById(pageId); if (!p) return; p.forceSend = !p.forceSend; emit(); }, // read the waiting pages (up to maxPerRun); never a page that is already read async startRun(opts) { opts = opts || {}; if (S.run && S.run.active) return null; let pages = opts.pages ? opts.pages.filter((p) => usable(p)) : toRead(); const maxPerRun = opts.maxPerRun || 100; const queued = pages.length; if (pages.length > maxPerRun) pages = pages.slice(0, maxPerRun); if (!pages.length) return null; const ctl = new AbortController(); S.run = { active: true, total: pages.length, queued, started: Date.now(), ctl, reading: 0, completed: 0, retrying: null, stopped: null }; S.notice = null; emit(); let res; try { res = await OR().readPages(pages, { signal: ctl.signal, force: !!opts.force, onPage: (p, ev) => { if (ev === "done" || ev === "failed") { S.rrev++; S.run.completed++; S.run.retrying = null; } S.run.reading = allPages().filter((x) => x.status === "reading").length; emit(); }, onEvent: (ev) => { if (ev.type === "retry") { S.run.retrying = ev; emit(); } } }); } catch (e) { res = { read: 0, failed: 0, stopped: null, aborted: false, error: e }; } const done = pages.filter((p) => p.status === "done").length, failed = pages.filter((p) => p.status === "failed").length; S.run = { active: false, total: pages.length, queued, done, failed, finished: Date.now(), started: S.run.started, stopped: res.stopped, aborted: res.aborted, left: queued - pages.length }; S.rrev++; emit(); try { window.K2BAiStatus && window.K2BAiStatus.refresh(); } catch (e) {} // pages used today / the key may have stopped working return res; }, cancelRun() { if (S.run && S.run.active && S.run.ctl) { S.run.cancelling = true; S.run.ctl.abort(); emit(); } }, // retry failed pages alone (explicit request) retry(ids) { const ps = (ids ? ids.map(pageById) : failedPages()).filter(Boolean); ps.forEach((p) => { p.status = "waiting"; p.error = ""; p.code = ""; }); S.rrev++; emit(); return api.startRun({ pages: ps }); }, // read a page that was already read once (the user asked for it) reread(pageId) { const p = pageById(pageId); if (!p) return null; p.status = "waiting"; p.result = null; p.error = ""; p.code = ""; S.rrev++; emit(); return api.startRun({ pages: [p], force: true }); }, // accept a suggested fix / a choice between two readings: decisions are kept so a rebuild keeps them applyFix(dsId, fixId) { const pk = packet(); const ds = pk && pk.datasets.find((d) => d.id === dsId); if (!ds) return { ok: false, error: "That list is no longer there." }; try { const nd = window.OldReportsData.applyFix(ds, fixId); S.decisions[dsId] = nd.decisions || []; S.rrev++; emit(); return { ok: true }; } catch (e) { return { ok: false, error: (e && e.message) || String(e) }; } }, // after an import the checks are recomputed: the chart (and so "accounts not in the chart") is different now markImported(dsId, info) { S.imported[dsId] = Object.assign({ at: new Date().toISOString() }, info || {}); S.rrev++; emit(); }, reset() { S.files.forEach((f) => { try { f.destroy && f.destroy(); } catch (e) {} }); S.files = []; S.decisions = {}; S.imported = {}; S.adding = null; S.addErrors = []; S.run = null; S.notice = null; S.rrev++; emit(); }, }; return api; })(); // A reload forgets the pages that were read (and reading them again costs money): ask before the tab is closed while there is something to lose. try { window.addEventListener("beforeunload", (e) => { const S = orSession.get(); const read = orSession.allPages().some((p) => p.status === "done"); const unfinished = read && (!Object.keys(S.imported).length || (orSession.packet() && orSession.packet().datasets.some((d) => !S.imported[d.id]))); if (unfinished || (S.run && S.run.active)) { e.preventDefault(); e.returnValue = ""; return ""; } }); } catch (e) {} const useOrSession = () => { const [, tick] = React.useReducer((n) => n + 1, 0); React.useEffect(() => orSession.subscribe(tick), []); return orSession; }; Object.assign(window.OldReports, { session: orSession }); /* ───────────────────────── the screen ───────────────────────── */ const orBox = { background: "#FFF", border: "1px solid #C9D6E3", borderRadius: 4 }; const orLvl = { ok: { icon: "✓", fg: "#176A38", bg: "#D9F0DE", bd: "#9ED9B1", word: "passed" }, warn: { icon: "⚠", fg: "#8A6400", bg: "#FFF0CC", bd: "#E7D49A", word: "to look at" }, error: { icon: "✕", fg: "#A02234", bg: "#F3D9D9", bd: "#E8B4B4", word: "to fix" }, }; const orPill = (lvl, text, extra) => { const c = orLvl[lvl] || orLvl.ok; return {text}; }; const orMoney = (n) => "$" + (+n || 0).toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const orTag = (name) => { const b = String(name || "").replace(/\.[^.]+$/, "").replace(/^old-/, ""); const f = b.split("-")[0]; return f && f.length <= 3 ? f : b.slice(0, 16); }; const orRef = (r) => (r && r.fileName ? orTag(r.fileName) + " p." + r.pageNo : ""); const orDownload = (name, text) => { const blob = new Blob([text], { type: "text/csv;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1500); }; const orTypeLabel = (t) => { const P = window.OldReportsData && window.OldReportsData.PAGE_TYPES; return (P && P[t] && P[t].label) || t || ""; }; const orFileLabel = (ds) => { const names = Array.from(new Set((ds.pages || []).map((p) => p.fileName))); return names.length === 1 ? names[0] : "Old reports packet (" + names.length + " files)"; }; // "Rows to look at": names of the rows (account / stock / deal numbers) a check points at const OrRows = ({ c }) => { const keys = (c.keys && c.keys.length ? c.keys : (c.rows || []).map((r) => "row " + (r + 1))); if (!keys.length) return null; const shown = keys.slice(0, 14); return (