fix people
This commit is contained in:
103
main.ts
103
main.ts
@@ -461,9 +461,57 @@ async function servePdfBytes(
|
|||||||
|
|
||||||
// ------- HTTP Server -------
|
// ------- 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) => {
|
Deno.serve(async (request) => {
|
||||||
const url = new URL(request.url);
|
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") {
|
if (url.pathname === "/api/state" && request.method === "GET") {
|
||||||
const configForClient = {
|
const configForClient = {
|
||||||
jsonPath: settings.jsonPath,
|
jsonPath: settings.jsonPath,
|
||||||
@@ -710,14 +758,45 @@ if (
|
|||||||
// Native resize tracking using Deno 2.9 BrowserWindow events
|
// Native resize tracking using Deno 2.9 BrowserWindow events
|
||||||
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
let sizeSeenFromEvent = false;
|
let sizeSeenFromEvent = false;
|
||||||
|
let resizeEventLogsLeft = 3;
|
||||||
try {
|
try {
|
||||||
win.addEventListener(
|
win.addEventListener(
|
||||||
"resize",
|
"resize",
|
||||||
((e: CustomEvent) => {
|
((e: CustomEvent) => {
|
||||||
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
|
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
|
||||||
// Store immediately but debounce writes
|
// The payload shape of the experimental backend is not settled;
|
||||||
const w = e.detail?.width ?? 0;
|
// accept detail.{width,height}, detail.{w,h}, detail as [w, h],
|
||||||
const h = e.detail?.height ?? 0;
|
// 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) {
|
if (w >= 1100 && h >= 700) {
|
||||||
settings.windowWidth = w;
|
settings.windowWidth = w;
|
||||||
settings.windowHeight = h;
|
settings.windowHeight = h;
|
||||||
@@ -742,12 +821,11 @@ try {
|
|||||||
"close",
|
"close",
|
||||||
(() => {
|
(() => {
|
||||||
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
|
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
|
||||||
// Prefer the size reported by native resize events. getSize() has
|
// Prefer sizes reported by native resize events or by the webview.
|
||||||
// been observed to return a stale value (the last programmatically
|
// getSize() has been observed to return a stale value (the last
|
||||||
// requested size) instead of the actual user-resized window size,
|
// programmatically requested size) instead of the actual window
|
||||||
// which caused the same size to be saved on every close. Only fall
|
// size, which caused the same size to be saved on every close.
|
||||||
// back to getSize() if no resize event was ever received.
|
if (!sizeSeenFromEvent && !sizeSeenFromWebview) {
|
||||||
if (!sizeSeenFromEvent) {
|
|
||||||
try {
|
try {
|
||||||
const [fw, fh] = win.getSize();
|
const [fw, fh] = win.getSize();
|
||||||
if (fw >= 1100 && fh >= 700) {
|
if (fw >= 1100 && fh >= 700) {
|
||||||
@@ -760,10 +838,11 @@ try {
|
|||||||
const path = resolveSettingsPath();
|
const path = resolveSettingsPath();
|
||||||
Deno.mkdirSync(dirname(path), { recursive: true });
|
Deno.mkdirSync(dirname(path), { recursive: true });
|
||||||
Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2));
|
Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2));
|
||||||
|
const source = sizeSeenFromEvent
|
||||||
|
? "resize events"
|
||||||
|
: (sizeSeenFromWebview ? "webview" : "getSize()");
|
||||||
console.log(
|
console.log(
|
||||||
`${PREFIX} Window size saved on close: ${settings.windowWidth}x${settings.windowHeight} (source: ${
|
`${PREFIX} Window size saved on close: ${settings.windowWidth}x${settings.windowHeight} (source: ${source})`,
|
||||||
sizeSeenFromEvent ? "resize events" : "getSize()"
|
|
||||||
})`,
|
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
29
src/data.ts
29
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[] {
|
export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] {
|
||||||
const groups = new Map<string, BuyerDocument[]>();
|
const groups = new Map<string, BuyerDocument[]>();
|
||||||
for (const doc of documents) {
|
for (const doc of documents) {
|
||||||
@@ -33,6 +44,24 @@ export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] {
|
|||||||
groups.set(key, bucket);
|
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()]
|
return [...groups.entries()]
|
||||||
.map(([key, docs]) => {
|
.map(([key, docs]) => {
|
||||||
docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||||
|
|||||||
63
web/app.js
63
web/app.js
@@ -51,6 +51,19 @@ const esc = (value) =>
|
|||||||
"'": "'",
|
"'": "'",
|
||||||
})[char]);
|
})[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) {
|
function buildGroups(docs) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
docs.forEach((doc, index) => {
|
docs.forEach((doc, index) => {
|
||||||
@@ -71,7 +84,33 @@ function buildGroups(docs) {
|
|||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
map.set(key, group);
|
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() {
|
function visibleGroups() {
|
||||||
@@ -299,3 +338,25 @@ 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
|
||||||
Reference in New Issue
Block a user