fix people

This commit is contained in:
2026-07-19 13:37:51 -05:00
parent 068debee16
commit 20960f97c0
3 changed files with 184 additions and 15 deletions

103
main.ts
View File

@@ -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<typeof setTimeout> | 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<string, unknown>;
const detail = anyEvent.detail as
| Record<string, unknown>
| 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(

View File

@@ -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<string, BuyerDocument[]>();
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<string, string>(); // 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))
);
}
}

View File

@@ -51,6 +51,19 @@ const esc = (value) =>
"'": "&#39;",
})[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();
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