diff --git a/main.ts b/main.ts index 518ca49..2b267c7 100644 --- a/main.ts +++ b/main.ts @@ -461,9 +461,57 @@ async function servePdfBytes( // ------- HTTP Server ------- +// ---- Webview-reported window size (fallback tracking) ---- +// Both getSize() and (apparently) the native resize events are unreliable +// in the experimental desktop backend, so the frontend reports its viewport +// size via POST /api/window-metrics. The first report arrives ~800ms after +// startup, i.e. after the enforced setSize, and is used to calibrate the +// per-session offset between the outer window size and the webview viewport +// (titlebar + borders). Later reports are translated back to outer-window +// coordinates with that offset. Calibrating per session means any error +// cancels out and the window cannot shrink a little on every restart. +let webviewMetricsOffset: { dw: number; dh: number } | null = null; +let sizeSeenFromWebview = false; + Deno.serve(async (request) => { const url = new URL(request.url); + if (url.pathname === "/api/window-metrics" && request.method === "POST") { + try { + const body = await request.json() as { + innerWidth?: number; + innerHeight?: number; + }; + const iw = Math.round(Number(body.innerWidth) || 0); + const ih = Math.round(Number(body.innerHeight) || 0); + if (iw > 200 && ih > 200) { + if (webviewMetricsOffset === null) { + // First report: window is still at the enforced startup size, + // so the difference to the viewport is the decoration size. + webviewMetricsOffset = { + dw: Math.min(Math.max(clamped.windowWidth - iw, 0), 200), + dh: Math.min(Math.max(clamped.windowHeight - ih, 0), 200), + }; + console.log( + `${PREFIX} Window metrics calibrated: viewport ${iw}x${ih}, decoration offset ${webviewMetricsOffset.dw}x${webviewMetricsOffset.dh}`, + ); + } else { + const w = iw + webviewMetricsOffset.dw; + const h = ih + webviewMetricsOffset.dh; + if (w >= 1100 && h >= 700) { + settings.windowWidth = w; + settings.windowHeight = h; + sizeSeenFromWebview = true; + console.log(`${PREFIX} Window size (from webview): ${w}x${h}`); + } + } + } + return json({ ok: true }); + } catch { + return json({ ok: false }, 400); + } + } + if (url.pathname === "/api/state" && request.method === "GET") { const configForClient = { jsonPath: settings.jsonPath, @@ -710,14 +758,45 @@ if ( // Native resize tracking using Deno 2.9 BrowserWindow events let resizeTimer: ReturnType | undefined; let sizeSeenFromEvent = false; +let resizeEventLogsLeft = 3; try { win.addEventListener( "resize", ((e: CustomEvent) => { if (resizeTimer !== undefined) clearTimeout(resizeTimer); - // Store immediately but debounce writes - const w = e.detail?.width ?? 0; - const h = e.detail?.height ?? 0; + // The payload shape of the experimental backend is not settled; + // accept detail.{width,height}, detail.{w,h}, detail as [w, h], + // and width/height directly on the event object. + const anyEvent = e as unknown as Record; + const detail = anyEvent.detail as + | Record + | number[] + | undefined; + let w = 0; + let h = 0; + if (Array.isArray(detail)) { + w = Number(detail[0]) || 0; + h = Number(detail[1]) || 0; + } else if (detail && typeof detail === "object") { + w = Number(detail.width ?? detail.w) || 0; + h = Number(detail.height ?? detail.h) || 0; + } + if (!w) w = Number(anyEvent.width) || 0; + if (!h) h = Number(anyEvent.height) || 0; + + // Log the first few raw events so we can see what the backend + // actually delivers (helps debug the unreliable size reporting). + if (resizeEventLogsLeft > 0) { + resizeEventLogsLeft--; + try { + console.log( + `${PREFIX} Native resize event: parsed=${w}x${h}, detail=${ + JSON.stringify(detail) + }, event.width/height=${anyEvent.width}/${anyEvent.height}`, + ); + } catch { /* ok */ } + } + if (w >= 1100 && h >= 700) { settings.windowWidth = w; settings.windowHeight = h; @@ -742,12 +821,11 @@ try { "close", (() => { if (resizeTimer !== undefined) clearTimeout(resizeTimer); - // Prefer the size reported by native resize events. getSize() has - // been observed to return a stale value (the last programmatically - // requested size) instead of the actual user-resized window size, - // which caused the same size to be saved on every close. Only fall - // back to getSize() if no resize event was ever received. - if (!sizeSeenFromEvent) { + // Prefer sizes reported by native resize events or by the webview. + // getSize() has been observed to return a stale value (the last + // programmatically requested size) instead of the actual window + // size, which caused the same size to be saved on every close. + if (!sizeSeenFromEvent && !sizeSeenFromWebview) { try { const [fw, fh] = win.getSize(); if (fw >= 1100 && fh >= 700) { @@ -760,10 +838,11 @@ try { const path = resolveSettingsPath(); Deno.mkdirSync(dirname(path), { recursive: true }); Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2)); + const source = sizeSeenFromEvent + ? "resize events" + : (sizeSeenFromWebview ? "webview" : "getSize()"); console.log( - `${PREFIX} Window size saved on close: ${settings.windowWidth}x${settings.windowHeight} (source: ${ - sizeSeenFromEvent ? "resize events" : "getSize()" - })`, + `${PREFIX} Window size saved on close: ${settings.windowWidth}x${settings.windowHeight} (source: ${source})`, ); } catch (err) { console.error( diff --git a/src/data.ts b/src/data.ts index 3b799f6..be7f7a6 100644 --- a/src/data.ts +++ b/src/data.ts @@ -24,6 +24,17 @@ export function validateDocuments(value: unknown): BuyerDocument[] { }); } +// Normalized person key from the extracted buyer name: lowercase, +// punctuation stripped, tokens sorted for order-insensitive comparison. +function personKey(docs: BuyerDocument[]): string | null { + const name = docs.map((d) => d.prospective_buyer).find((v) => text(v)); + if (!name) return null; + const tokens = String(name).toLocaleLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, " ").trim().split(/\s+/).filter(Boolean) + .sort(); + return tokens.length ? tokens.join(" ") : null; +} + export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] { const groups = new Map(); for (const doc of documents) { @@ -33,6 +44,24 @@ export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] { groups.set(key, bucket); } + // Merge filename-based buckets that share the same prospective_buyer + // (catches typos in scan filenames, e.g. "Zaboor" vs "Zahoor"). + const byPerson = new Map(); // personKey -> bucket key + for (const [key, docs] of [...groups.entries()]) { + const pKey = personKey(docs); + if (!pKey) continue; + const existingKey = byPerson.get(pKey); + if (existingKey === undefined) { + byPerson.set(pKey, key); + continue; + } + const target = groups.get(existingKey); + if (target) { + target.push(...docs); + groups.delete(key); + } + } + return [...groups.entries()] .map(([key, docs]) => { docs.sort((a, b) => a.file_name.localeCompare(b.file_name)); @@ -66,4 +95,4 @@ export function filterGroups( return groups.filter((group) => terms.every((term) => group.searchText.includes(term)) ); -} +} \ No newline at end of file diff --git a/web/app.js b/web/app.js index 4391290..84fd8dc 100644 --- a/web/app.js +++ b/web/app.js @@ -51,6 +51,19 @@ const esc = (value) => "'": "'", })[char]); +// Normalized person key from the vision-extracted buyer name: lowercase, +// punctuation stripped, name tokens sorted so "Zahoor Bilal" and +// "Bilal Zahoor" compare equal. +function personKey(group) { + const name = group.docs.map((d) => d.prospective_buyer).find((v) => + typeof v === "string" && v.trim() + ); + if (!name) return null; + const tokens = name.toLocaleLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ") + .trim().split(/\s+/).filter(Boolean).sort(); + return tokens.length ? tokens.join(" ") : null; +} + function buildGroups(docs) { const map = new Map(); docs.forEach((doc, index) => { @@ -71,7 +84,33 @@ function buildGroups(docs) { ].filter(Boolean).join(" "); map.set(key, group); }); - return [...map.values()].sort((a, b) => a.key.localeCompare(b.key)); + + // Second pass: merge filename-based groups that refer to the same person + // according to the extracted prospective_buyer. This catches typos in the + // scan filenames (e.g. "Zaboor, Bilal" vs "Zahoor, Bilal") which would + // otherwise show the same buyer twice. Groups without a prospective_buyer + // are never merged. + const byPerson = new Map(); + const merged = []; + for (const group of map.values()) { + const pKey = personKey(group); + const target = pKey ? byPerson.get(pKey) : undefined; + if (target) { + target.docs.push(...group.docs); + target.text += " " + group.text; + continue; + } + if (pKey) byPerson.set(pKey, group); + merged.push(group); + } + for (const group of merged) { + group.docs.sort((a, b) => a.file_name.localeCompare(b.file_name)); + const preferred = group.docs.find((d) => + typeof d.prospective_buyer === "string" && d.prospective_buyer.trim() + ); + if (preferred) group.displayName = preferred.prospective_buyer; + } + return merged.sort((a, b) => a.key.localeCompare(b.key)); } function visibleGroups() { @@ -298,4 +337,26 @@ async function load() { } } -void load(); \ No newline at end of file +void load(); + +// ---- Window size reporting ---- +// The backend's native size APIs are unreliable (see main.ts), so the +// webview reports its own viewport size. The first report, sent shortly +// after startup, calibrates the decoration offset on the backend; later +// reports track user resizes. +let metricsTimer = null; +function reportWindowMetrics() { + fetch("/api/window-metrics", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + innerWidth: globalThis.innerWidth, + innerHeight: globalThis.innerHeight, + }), + }).catch(() => {}); +} +globalThis.addEventListener("resize", () => { + clearTimeout(metricsTimer); + metricsTimer = setTimeout(reportWindowMetrics, 250); +}); +setTimeout(reportWindowMetrics, 800); // calibration report \ No newline at end of file