From 201f5c1b95a890c83ac3ed4912a79ec538064b8d Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 15 Jul 2026 16:42:28 -0500 Subject: [PATCH] init --- .bizmatch-qc.json | 4 + .gitignore | 2 + README.md | 27 ++++++ deno.json | 21 +++++ main.ts | 188 +++++++++++++++++++++++++++++++++++++++++ src/data.ts | 45 ++++++++++ src/paths.ts | 13 +++ src/types.ts | 35 ++++++++ web/app.js | 208 ++++++++++++++++++++++++++++++++++++++++++++++ web/index.html | 44 ++++++++++ web/styles.css | 5 ++ 11 files changed, 592 insertions(+) create mode 100644 .bizmatch-qc.json create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deno.json create mode 100644 main.ts create mode 100644 src/data.ts create mode 100644 src/paths.ts create mode 100644 src/types.ts create mode 100644 web/app.js create mode 100644 web/index.html create mode 100644 web/styles.css diff --git a/.bizmatch-qc.json b/.bizmatch-qc.json new file mode 100644 index 0000000..e87d6ea --- /dev/null +++ b/.bizmatch-qc.json @@ -0,0 +1,4 @@ +{ + "jsonPath": "/home/aknuth/git/ai-bayarea/buyers_vision.json", + "pdfBaseDirectory": "/mnt/bizmatch-nas/AA Buyers NDA's/Buyers NDA's A-Z/" +} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f437e15 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +tests +sample-data \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc6462b --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# BizMatch QC Desktop 0.1.2 + +Desktop quality-control viewer for BizMatch buyer documents. + +## Start + +```bash +deno task dev +``` + +Open **Settings** and enter: + +- the absolute path to the combined `buyers_vision.json` +- the absolute PDF base directory + +The final PDF path is: + +```text +PDF base directory / _letter / file_name +``` + +Spaces and apostrophes in paths are supported. Settings are validated before they replace the current data. Errors are displayed in the Settings dialog and written to the terminal. + + +## 0.1.3 + +Added Email, State, Notes Page, Buyer Info Page, and CA Page to the QC field panel. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..50f067e --- /dev/null +++ b/deno.json @@ -0,0 +1,21 @@ +{ + "name": "bizmatch-qc-desktop", + "version": "0.1.3", + "exports": "./main.ts", + "tasks": { + "dev": "deno desktop --hmr --backend cef --allow-read --allow-write main.ts", + "start": "deno desktop --backend cef --allow-read --allow-write main.ts", + "test": "deno test --allow-read tests/" + }, + "desktop": { + "app": { + "name": "BizMatch QC", + "identifier": "net.bizmatch.qc" + }, + "backend": "cef", + "output": { + "windows": "./dist/BizMatch-QC", + "linux": "./dist/bizmatch-qc" + } + } +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..c218a78 --- /dev/null +++ b/main.ts @@ -0,0 +1,188 @@ +import { dirname, fromFileUrl, join } from "jsr:@std/path"; +import { validateDocuments } from "./src/data.ts"; +import { resolvePdfPath } from "./src/paths.ts"; +import type { BuyerDocument } from "./src/types.ts"; +import INDEX_HTML from "./web/index.html" with { type: "text" }; +import APP_JS from "./web/app.js" with { type: "text" }; +import STYLES_CSS from "./web/styles.css" with { type: "text" }; +import SAMPLE_DOCUMENTS from "./sample-data/buyers_vision_anonymous.json" with { type: "json" }; + +const ROOT = dirname(fromFileUrl(import.meta.url)); +const CONFIG_PATH = join(Deno.cwd(), ".bizmatch-qc.json"); + +type Config = { jsonPath: string; pdfBaseDirectory: string }; +type LoadResult = { documents: BuyerDocument[]; source: "sample" | "file" }; + +let config: Config = await readConfig(); +let documents: BuyerDocument[] = []; +let loadError = ""; +let dataSource: "sample" | "file" = "sample"; + +try { + const loaded = await loadDocuments(config.jsonPath); + documents = loaded.documents; + dataSource = loaded.source; +} catch (error) { + loadError = errorMessage(error); + console.error(`[BizMatch QC] Initial data load failed: ${loadError}`); + documents = validateDocuments(SAMPLE_DOCUMENTS); + dataSource = "sample"; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function readConfig(): Promise { + try { + return { + jsonPath: "", + pdfBaseDirectory: "", + ...JSON.parse(await Deno.readTextFile(CONFIG_PATH)), + }; + } catch { + return { jsonPath: "", pdfBaseDirectory: "" }; + } +} + +async function loadDocuments(jsonPath: string): Promise { + if (!jsonPath.trim()) { + return { documents: validateDocuments(SAMPLE_DOCUMENTS), source: "sample" }; + } + + let raw: string; + try { + raw = await Deno.readTextFile(jsonPath); + } catch (error) { + throw new Error(`Cannot read JSON file "${jsonPath}": ${errorMessage(error)}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid JSON in "${jsonPath}": ${errorMessage(error)}`); + } + + try { + return { documents: validateDocuments(parsed), source: "file" }; + } catch (error) { + throw new Error(`JSON validation failed for "${jsonPath}": ${errorMessage(error)}`); + } +} + +async function saveConfig(next: Config): Promise { + const normalized: Config = { + jsonPath: next.jsonPath.trim(), + pdfBaseDirectory: next.pdfBaseDirectory.trim(), + }; + + // Load first. Never replace valid in-memory data with an empty list on failure. + const loaded = await loadDocuments(normalized.jsonPath); + + if (normalized.pdfBaseDirectory) { + try { + const stat = await Deno.stat(normalized.pdfBaseDirectory); + if (!stat.isDirectory) throw new Error("Path exists but is not a directory."); + } catch (error) { + throw new Error(`Cannot access PDF base directory "${normalized.pdfBaseDirectory}": ${errorMessage(error)}`); + } + } + + await Deno.writeTextFile(CONFIG_PATH, JSON.stringify(normalized, null, 2)); + config = normalized; + documents = loaded.documents; + dataSource = loaded.source; + loadError = ""; + console.log(`[BizMatch QC] Loaded ${documents.length} documents from ${dataSource === "sample" ? "embedded sample data" : normalized.jsonPath}.`); + if (normalized.pdfBaseDirectory) { + console.log(`[BizMatch QC] PDF base directory: ${normalized.pdfBaseDirectory}`); + } +} + +function json(data: unknown, status = 200) { + return Response.json(data, { + status, + headers: { "cache-control": "no-store" }, + }); +} + +function staticFile(pathname: string): Response { + const files: Record = { + "/": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, + "/index.html": { body: INDEX_HTML, type: "text/html; charset=utf-8" }, + "/app.js": { body: APP_JS, type: "text/javascript; charset=utf-8" }, + "/styles.css": { body: STYLES_CSS, type: "text/css; charset=utf-8" }, + }; + const file = files[pathname]; + if (!file) return new Response("Not found", { status: 404 }); + return new Response(file.body, { + headers: { "content-type": file.type, "cache-control": "no-store" }, + }); +} + +Deno.serve(async (request) => { + const url = new URL(request.url); + + if (url.pathname === "/api/state" && request.method === "GET") { + return json({ config, documents, loadError, dataSource }); + } + + if (url.pathname === "/api/config" && request.method === "POST") { + try { + const body = await request.json() as Config; + await saveConfig({ + jsonPath: body.jsonPath ?? "", + pdfBaseDirectory: body.pdfBaseDirectory ?? "", + }); + return json({ ok: true, count: documents.length, dataSource }); + } catch (error) { + const message = errorMessage(error); + loadError = message; + console.error(`[BizMatch QC] Settings rejected: ${message}`); + return json({ error: message }, 400); + } + } + + if (url.pathname === "/api/pdf" && request.method === "GET") { + const index = Number(url.searchParams.get("index")); + const doc = documents[index]; + if (!Number.isInteger(index) || !doc) { + return json({ error: "Unknown document index." }, 404); + } + + try { + const path = resolvePdfPath(config.pdfBaseDirectory, doc); + const stat = await Deno.stat(path); + if (!stat.isFile) throw new Error("Resolved path is not a file."); + const file = await Deno.open(path, { read: true }); + return new Response(file.readable, { + headers: { + "content-type": "application/pdf", + "content-length": String(stat.size), + "content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(doc.file_name)}`, + "cache-control": "no-store", + }, + }); + } catch (error) { + const message = errorMessage(error); + console.error(`[BizMatch QC] PDF open failed for "${doc.file_name}": ${message}`); + return json({ error: message }, 404); + } + } + + return staticFile(url.pathname); +}); + +console.log(`[BizMatch QC] Listening on http://127.0.0.1:42469/`); +console.log(`[BizMatch QC] Config file: ${CONFIG_PATH}`); +console.log(`[BizMatch QC] Current data source: ${dataSource === "sample" ? "embedded sample data" : config.jsonPath}`); +console.log(`[BizMatch QC] Documents loaded: ${documents.length}`); +if (loadError) console.error(`[BizMatch QC] ${loadError}`); + +const win = new Deno.BrowserWindow({ + title: "BizMatch QC", + width: 1500, + height: 950, +}); +win.show(); diff --git a/src/data.ts b/src/data.ts new file mode 100644 index 0000000..852f359 --- /dev/null +++ b/src/data.ts @@ -0,0 +1,45 @@ +import type { BuyerDocument, PersonGroup } from "./types.ts"; + +const text = (value: unknown) => typeof value === "string" ? value.trim() : ""; +const normalize = (value: unknown) => text(value).toLocaleLowerCase(); + +export function validateDocuments(value: unknown): BuyerDocument[] { + if (!Array.isArray(value)) throw new Error("JSON root must be an array."); + return value.map((item, index) => { + if (!item || typeof item !== "object") throw new Error(`Record ${index + 1} is not an object.`); + const doc = item as Partial; + if (!text(doc.file_name) || !text(doc.name_from_filename) || !text(doc._letter)) { + throw new Error(`Record ${index + 1} is missing file_name, name_from_filename, or _letter.`); + } + return doc as BuyerDocument; + }); +} + +export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] { + const groups = new Map(); + for (const doc of documents) { + const key = doc.name_from_filename.trim(); + const bucket = groups.get(key) ?? []; + bucket.push(doc); + groups.set(key, bucket); + } + + return [...groups.entries()] + .map(([key, docs]) => { + docs.sort((a, b) => a.file_name.localeCompare(b.file_name)); + const preferred = docs.find((d) => text(d.prospective_buyer))?.prospective_buyer; + const searchText = normalize([ + key, + preferred, + ...docs.flatMap((d) => [d.types_of_business_raw, d.notes_business_raw, d.address]), + ].filter(Boolean).join("\n")); + return { key, displayName: text(preferred) || key, documents: docs, searchText }; + }) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +export function filterGroups(groups: PersonGroup[], query: string): PersonGroup[] { + const terms = normalize(query).split(/\s+/).filter(Boolean); + if (!terms.length) return groups; + return groups.filter((group) => terms.every((term) => group.searchText.includes(term))); +} diff --git a/src/paths.ts b/src/paths.ts new file mode 100644 index 0000000..9d4a315 --- /dev/null +++ b/src/paths.ts @@ -0,0 +1,13 @@ +import { isAbsolute, join, normalize, relative } from "jsr:@std/path"; +import type { BuyerDocument } from "./types.ts"; + +export function resolvePdfPath(baseDirectory: string, doc: BuyerDocument): string { + if (!baseDirectory.trim()) throw new Error("PDF base directory is not configured."); + if (!isAbsolute(baseDirectory)) throw new Error("PDF base directory must be absolute."); + + const base = normalize(baseDirectory); + const full = normalize(join(base, doc._letter, doc.file_name)); + const rel = relative(base, full); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Resolved PDF path escapes the base directory."); + return full; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b5b6b8b --- /dev/null +++ b/src/types.ts @@ -0,0 +1,35 @@ +export interface BuyerDocument { + file_name: string; + name_from_filename: string; + _letter: string; + prospective_buyer?: string | null; + name_company?: string | null; + company?: string | null; + phone?: string | null; + cell?: string | null; + email?: string | null; + address?: string | null; + state?: string | null; + how_did_you_hear?: string | null; + interested_in_updates?: string | null; + types_of_business_raw?: string | null; + notes_business_raw?: string | null; + background_experience?: string | null; + total_purchase_price?: string | null; + down_payment?: string | null; + down_payment_raw?: string | null; + date_of_introduction?: string | null; + _doc_type?: string | null; + _pages_total?: number | null; + _notes_page?: number | null; + _info_page?: number | null; + _ca_page?: number | null; + _vision_error?: string | null; +} + +export interface PersonGroup { + key: string; + displayName: string; + documents: BuyerDocument[]; + searchText: string; +} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..5e08cd7 --- /dev/null +++ b/web/app.js @@ -0,0 +1,208 @@ +let state; +let groups = []; +let selectedIndex = -1; +let currentPdfUrl = ""; + +const people = document.querySelector("#people"); +const fields = document.querySelector("#fields"); +const pdf = document.querySelector("#pdf"); +const viewer = document.querySelector(".viewer"); +const pdfMessage = document.querySelector("#pdfMessage"); +const status = document.querySelector("#status"); +const search = document.querySelector("#search"); +const errorBanner = document.querySelector("#errorBanner"); + +const fieldDefs = [ + ["Name / Company", "name_company"], + ["Prospective Buyer", "prospective_buyer"], + ["Company", "company"], + ["Phone", "phone"], + ["Cell", "cell"], + ["Email", "email"], + ["Address", "address"], + ["State", "state"], + ["How did you hear", "how_did_you_hear"], + ["Interested in updates", "interested_in_updates"], + ["Background experience", "background_experience"], + ["Types of businesses", "types_of_business_raw"], + ["Businesses from Notes", "notes_business_raw"], + ["Date of Introduction", "date_of_introduction"], + ["Down payment", "down_payment_raw"], + ["Total purchase price", "total_purchase_price"], + ["Notes Page", "_notes_page"], + ["Buyer Info Page", "_info_page"], + ["CA Page", "_ca_page"], +]; + +const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (char) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", +})[char]); + +function buildGroups(docs) { + const map = new Map(); + docs.forEach((doc, index) => { + const key = doc.name_from_filename; + const group = map.get(key) || { + key, + displayName: doc.prospective_buyer || key, + docs: [], + text: "", + }; + group.docs.push({ ...doc, index }); + group.text += " " + [ + key, + doc.prospective_buyer, + doc.types_of_business_raw, + doc.notes_business_raw, + doc.address, + ].filter(Boolean).join(" "); + map.set(key, group); + }); + return [...map.values()].sort((a, b) => a.key.localeCompare(b.key)); +} + +function visibleGroups() { + const terms = search.value.toLowerCase().trim().split(/\s+/).filter(Boolean); + return groups.filter((group) => terms.every((term) => group.text.toLowerCase().includes(term))); +} + +function updateStatus(shownCount = visibleGroups().length) { + const source = state.dataSource === "sample" ? "sample data" : "JSON file"; + status.textContent = `${shownCount} people / ${state.documents.length} documents · ${source}`; + status.className = ""; +} + +function showError(message) { + errorBanner.textContent = message; + errorBanner.hidden = !message; +} + +function renderList() { + const shown = visibleGroups(); + people.innerHTML = shown.map((group) => ` +
+
${esc(group.displayName)} (${group.docs.length})
+ ${group.docs.map((doc) => ` + `).join("")} +
+ `).join(""); + updateStatus(shown.length); +} + +async function loadPdf(index) { + if (currentPdfUrl) { + URL.revokeObjectURL(currentPdfUrl); + currentPdfUrl = ""; + } + pdf.removeAttribute("src"); + viewer.classList.remove("loaded"); + pdfMessage.hidden = false; + pdfMessage.textContent = "Loading PDF…"; + + try { + const response = await fetch(`/api/pdf?index=${index}`, { cache: "no-store" }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `PDF request failed with HTTP ${response.status}.`); + } + const blob = await response.blob(); + currentPdfUrl = URL.createObjectURL(blob); + pdf.src = currentPdfUrl; + viewer.classList.add("loaded"); + pdfMessage.hidden = true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pdfMessage.textContent = `Cannot open PDF: ${message}`; + pdfMessage.hidden = false; + } +} + +function select(index) { + selectedIndex = index; + const doc = state.documents[index]; + if (!doc) return; + fields.innerHTML = ` +

${esc(doc.name_from_filename)}

+

${esc(doc.file_name)} · ${esc(doc._doc_type || "unknown")} · ${esc(doc._pages_total ?? "?")} pages

+ ${doc._vision_error ? `

Vision error: ${esc(doc._vision_error)}

` : ""} + ${fieldDefs.map(([label, key]) => ` +
${label}
${esc(doc[key] || "—")}
+ `).join("")} + `; + void loadPdf(index); + renderList(); +} + +people.addEventListener("click", (event) => { + const button = event.target.closest("[data-index]"); + if (button) select(Number(button.dataset.index)); +}); + +search.addEventListener("input", renderList); + +document.addEventListener("keydown", (event) => { + if (["INPUT", "TEXTAREA"].includes(document.activeElement.tagName)) return; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const delta = event.key === "ArrowDown" ? 1 : -1; + select(Math.max(0, Math.min(state.documents.length - 1, selectedIndex < 0 ? 0 : selectedIndex + delta))); + } +}); + +const dialog = document.querySelector("#settingsDialog"); +const jsonPath = document.querySelector("#jsonPath"); +const pdfBase = document.querySelector("#pdfBase"); +const settingsError = document.querySelector("#settingsError"); +const saveSettings = document.querySelector("#saveSettings"); + +document.querySelector("#settings").onclick = () => { + jsonPath.value = state.config.jsonPath; + pdfBase.value = state.config.pdfBaseDirectory; + settingsError.hidden = true; + settingsError.textContent = ""; + dialog.showModal(); +}; + +saveSettings.onclick = async (event) => { + event.preventDefault(); + saveSettings.disabled = true; + settingsError.hidden = true; + + try { + const response = await fetch("/api/config", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonPath: jsonPath.value, + pdfBaseDirectory: pdfBase.value, + }), + }); + const body = await response.json(); + if (!response.ok) throw new Error(body.error || "Could not save settings."); + dialog.close(); + await load(); + } catch (error) { + settingsError.textContent = error instanceof Error ? error.message : String(error); + settingsError.hidden = false; + } finally { + saveSettings.disabled = false; + } +}; + +async function load() { + try { + const response = await fetch("/api/state", { cache: "no-store" }); + if (!response.ok) throw new Error(`State request failed with HTTP ${response.status}.`); + state = await response.json(); + groups = buildGroups(state.documents); + showError(state.loadError || ""); + renderList(); + if (state.documents.length) select(0); + } catch (error) { + showError(error instanceof Error ? error.message : String(error)); + } +} + +void load(); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..03e8858 --- /dev/null +++ b/web/index.html @@ -0,0 +1,44 @@ + + + + + + BizMatch QC + + + +
+ BizMatch QC + + + +
+ +
+ +
+
+ +
Select a document
+
+
+ +
+

Settings

+ + + +
+ + +
+

Full PDF path: base directory / _letter / file_name

+
+
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..93a7eed --- /dev/null +++ b/web/styles.css @@ -0,0 +1,5 @@ +*{box-sizing:border-box}body{margin:0;font:14px system-ui,sans-serif;color:#202124}header{height:52px;display:flex;align-items:center;gap:14px;padding:8px 14px;border-bottom:1px solid #ddd}header strong{font-size:18px}header input{flex:1;max-width:620px;padding:8px}header span{margin-left:auto;color:#666}main{height:calc(100vh - 52px);display:grid;grid-template-columns:330px 430px minmax(500px,1fr)}aside,.details{overflow:auto;border-right:1px solid #ddd}.person{border-bottom:1px solid #ddd}.person-title{font-weight:650;padding:10px 12px;background:#f6f7f8}.doc{display:block;width:100%;border:0;border-top:1px solid #eee;background:white;text-align:left;padding:8px 14px;cursor:pointer}.doc:hover,.doc.active{background:#e9f1ff}.details{padding:14px}.field{margin-bottom:13px}.field b{display:block;font-size:12px;color:#666;margin-bottom:3px;text-transform:uppercase}.field div{white-space:pre-wrap}.viewer{position:relative;background:#555}.viewer iframe{width:100%;height:100%;border:0;background:white}.viewer #pdfMessage{position:absolute;inset:0;display:grid;place-items:center;color:white;pointer-events:none}.viewer.loaded #pdfMessage{display:none}dialog{width:min(720px,90vw)}dialog label{display:block;margin:12px 0;font-weight:600}dialog input{display:block;width:100%;padding:8px;margin-top:5px}.actions{display:flex;justify-content:flex-end;gap:8px}.hint{color:#666}.error{color:#a40000}.muted{color:#777} + +.error-banner { padding: 10px 16px; background: #fff1f1; border-bottom: 1px solid #c62828; color: #9b1c1c; white-space: pre-wrap; } +.dialog-error { margin-top: 12px; padding: 10px; border: 1px solid #c62828; background: #fff1f1; color: #9b1c1c; white-space: pre-wrap; } +#pdfMessage { white-space: pre-wrap; padding: 20px; color: #8a1c1c; }