Files
bizmatch-qc-desktop/main.ts
2026-07-15 17:38:48 -05:00

492 lines
15 KiB
TypeScript

import { dirname } from "@std/path";
import { validateDocuments } from "./src/data.ts";
import { resolvePdfPath } from "./src/paths.ts";
import type { BuyerDocument } from "./src/types.ts";
import {
type AppSettings,
clampWindowSize,
DEFAULTS,
readLegacyConfig,
resolveCacheDir,
resolveSettingsPath,
saveSettings as saveAppSettings,
} from "./src/settings.ts";
import { cacheOrGetPdf } from "./src/pdf_cache.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 PREFIX = "[BizMatch QC]";
let settings: AppSettings = await initializeSettings();
let documents: BuyerDocument[] = [];
let loadError = "";
let dataSource: "sample" | "file" = "sample";
try {
const loaded = await loadDocumentsForMode();
documents = loaded.documents;
dataSource = loaded.source;
} catch (error) {
loadError = errorMessage(error);
console.error(`${PREFIX} Initial data load failed: ${loadError}`);
documents = validateDocuments(SAMPLE_DOCUMENTS);
dataSource = "sample";
}
async function initializeSettings(): Promise<AppSettings> {
const path = resolveSettingsPath();
console.log(`${PREFIX} Settings path: ${path}`);
let result: AppSettings;
let fileExisted = false;
try {
const content = await Deno.readTextFile(path);
result = JSON.parse(content) as unknown as AppSettings;
fileExisted = true;
} catch {
result = { ...DEFAULTS };
}
const validated = { ...DEFAULTS };
if (
fileExisted && result && typeof result === "object" &&
!Array.isArray(result)
) {
const obj = result as unknown as Record<string, unknown>;
if (typeof obj.jsonPath === "string") validated.jsonPath = obj.jsonPath;
if (typeof obj.pdfBaseDirectory === "string") {
validated.pdfBaseDirectory = obj.pdfBaseDirectory;
}
if (typeof obj.useAnonymousData === "boolean") {
validated.useAnonymousData = obj.useAnonymousData;
}
if (
typeof obj.windowWidth === "number" && !isNaN(obj.windowWidth) &&
obj.windowWidth >= 1100
) {
validated.windowWidth = obj.windowWidth;
}
if (
typeof obj.windowHeight === "number" && !isNaN(obj.windowHeight) &&
obj.windowHeight >= 700
) {
validated.windowHeight = obj.windowHeight;
}
console.log(`${PREFIX} Settings loaded successfully.`);
} else {
console.log(`${PREFIX} No existing settings file found. Using defaults.`);
}
if (!fileExisted) {
const legacy = await readLegacyConfig();
if (legacy) {
validated.jsonPath = validated.jsonPath || legacy.jsonPath;
validated.pdfBaseDirectory = validated.pdfBaseDirectory ||
legacy.pdfBaseDirectory;
await saveAppSettings(validated);
}
}
return validated;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function loadDocumentsFromFile(
jsonPath: string,
): Promise<{ documents: BuyerDocument[]; source: "file" }> {
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 loadDocumentsForMode(): Promise<
{ documents: BuyerDocument[]; source: "sample" | "file" }
> {
if (settings.useAnonymousData) {
return { documents: validateDocuments(SAMPLE_DOCUMENTS), source: "sample" };
}
if (!settings.jsonPath.trim()) {
return { documents: validateDocuments(SAMPLE_DOCUMENTS), source: "sample" };
}
return await loadDocumentsFromFile(settings.jsonPath);
}
async function applySettings(
next: {
jsonPath?: string;
pdfBaseDirectory?: string;
useAnonymousData?: boolean;
},
): Promise<void> {
const newSettings: AppSettings = {
...settings,
jsonPath: next.jsonPath !== undefined
? next.jsonPath.trim()
: settings.jsonPath,
pdfBaseDirectory: next.pdfBaseDirectory !== undefined
? next.pdfBaseDirectory.trim()
: settings.pdfBaseDirectory,
useAnonymousData: next.useAnonymousData !== undefined
? next.useAnonymousData
: settings.useAnonymousData,
};
if (newSettings.useAnonymousData) {
const docs = validateDocuments(SAMPLE_DOCUMENTS);
settings = newSettings;
documents = docs;
dataSource = "sample";
loadError = "";
await saveAppSettings(settings);
console.log(
`${PREFIX} Switched to anonymized sample data (${docs.length} documents).`,
);
return;
}
if (!newSettings.jsonPath.trim()) {
settings = newSettings;
documents = validateDocuments(SAMPLE_DOCUMENTS);
dataSource = "sample";
loadError = "";
await saveAppSettings(settings);
console.log(`${PREFIX} No JSON path configured; using sample data.`);
return;
}
const loaded = await loadDocumentsFromFile(newSettings.jsonPath);
if (newSettings.pdfBaseDirectory) {
try {
const stat = await Deno.stat(newSettings.pdfBaseDirectory);
if (!stat.isDirectory) {
throw new Error("Path exists but is not a directory.");
}
} catch (error) {
throw new Error(
`Cannot access PDF base directory "${newSettings.pdfBaseDirectory}": ${
errorMessage(error)
}`,
);
}
}
await saveAppSettings(newSettings);
settings = newSettings;
documents = loaded.documents;
dataSource = loaded.source;
loadError = "";
console.log(
`${PREFIX} Loaded ${documents.length} documents from ${settings.jsonPath}.`,
);
if (settings.pdfBaseDirectory) {
console.log(`${PREFIX} PDF base directory: ${settings.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<string, { body: string; type: string }> = {
"/": { 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" },
});
}
function syncSaveSettings() {
try {
const path = resolveSettingsPath();
Deno.mkdirSync(dirname(path), { recursive: true });
Deno.writeTextFileSync(path, JSON.stringify(settings, null, 2));
console.log(
`${PREFIX} Window size saved: ${settings.windowWidth}x${settings.windowHeight}`,
);
} catch (err) {
console.error(
`${PREFIX} Failed to save settings on close: ${errorMessage(err)}`,
);
}
}
Deno.serve(async (request) => {
const url = new URL(request.url);
if (url.pathname === "/api/state" && request.method === "GET") {
const configForClient = {
jsonPath: settings.jsonPath,
pdfBaseDirectory: settings.pdfBaseDirectory,
useAnonymousData: settings.useAnonymousData,
};
return json({ config: configForClient, documents, loadError, dataSource });
}
if (url.pathname === "/api/config" && request.method === "POST") {
try {
interface ConfigBody {
jsonPath?: string;
pdfBaseDirectory?: string;
useAnonymousData?: boolean;
}
const body = await request.json() as ConfigBody;
await applySettings({
jsonPath: body.jsonPath ?? settings.jsonPath,
pdfBaseDirectory: body.pdfBaseDirectory ?? settings.pdfBaseDirectory,
useAnonymousData: body.useAnonymousData ?? settings.useAnonymousData,
});
return json({ ok: true, count: documents.length, dataSource });
} catch (error) {
const message = errorMessage(error);
loadError = message;
console.error(`${PREFIX} Settings rejected: ${message}`);
return json({ error: message }, 400);
}
}
if (url.pathname === "/api/window-size" && request.method === "POST") {
try {
const body = await request.json() as { width?: number; height?: number };
if (typeof body.width === "number" && typeof body.height === "number") {
settings.windowWidth = Math.max(1100, Math.round(body.width));
settings.windowHeight = Math.max(700, Math.round(body.height));
await saveAppSettings(settings);
console.log(
`${PREFIX} Window size saved (fallback): ${settings.windowWidth}x${settings.windowHeight}`,
);
}
return json({ ok: true });
} catch {
return json({ ok: true });
}
}
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 sourcePath = resolvePdfPath(settings.pdfBaseDirectory, doc);
const startTotal = performance.now();
const cacheResult = await cacheOrGetPdf(sourcePath);
const cacheLookupMs = Math.round(performance.now() - startTotal);
let fileSize: number;
try {
fileSize = (await Deno.stat(cacheResult.path)).size;
} catch {
throw new Error(`Cached file is not readable: "${cacheResult.path}"`);
}
const rangeHeader = request.headers.get("range");
if (rangeHeader) {
const match = rangeHeader.match(/^bytes=(\d+)-(\d*)$/);
if (match) {
const rangeStart = parseInt(match[1], 10);
const rangeEnd = match[2] ? parseInt(match[2], 10) : fileSize - 1;
const length = rangeEnd - rangeStart + 1;
if (
rangeStart < 0 || rangeEnd >= fileSize || rangeStart > rangeEnd
) {
return new Response(null, {
status: 416,
headers: {
"content-range": `bytes */${fileSize}`,
},
});
}
const file = await Deno.open(cacheResult.path, { read: true });
await file.seek(rangeStart, Deno.SeekMode.Start);
const buf = new Uint8Array(length);
let bytesRead = 0;
while (bytesRead < length) {
const n = await file.read(buf.subarray(bytesRead));
if (n === null) break;
bytesRead += n;
}
file.close();
const elapsed = Math.round(performance.now() - startTotal);
console.log(
`${PREFIX} PDF served (range ${rangeStart}-${rangeEnd}/${fileSize}): ${elapsed}ms, cache_lookup=${cacheLookupMs}ms`,
);
return new Response(buf.slice(0, bytesRead), {
status: 206,
headers: {
"content-type": "application/pdf",
"content-length": String(bytesRead),
"content-range": `bytes ${rangeStart}-${
rangeStart + bytesRead - 1
}/${fileSize}`,
"accept-ranges": "bytes",
"content-disposition": `inline; filename*=UTF-8''${
encodeURIComponent(doc.file_name)
}`,
...(cacheResult.stale
? { "x-bizmatch-cache-status": "stale" }
: {}),
},
});
}
}
const file = await Deno.open(cacheResult.path, { read: true });
const elapsed = Math.round(performance.now() - startTotal);
console.log(
`${PREFIX} PDF served (full ${fileSize}B): ${elapsed}ms, cache_lookup=${cacheLookupMs}ms`,
);
return new Response(file.readable, {
headers: {
"content-type": "application/pdf",
"content-length": String(fileSize),
"accept-ranges": "bytes",
"content-disposition": `inline; filename*=UTF-8''${
encodeURIComponent(doc.file_name)
}`,
...(cacheResult.stale ? { "x-bizmatch-cache-status": "stale" } : {}),
},
});
} catch (error) {
const message = errorMessage(error);
console.error(
`${PREFIX} PDF open failed for "${doc.file_name}": ${message}`,
);
return json({ error: message }, 404);
}
}
return staticFile(url.pathname);
});
console.log(`${PREFIX} Listening on http://127.0.0.1:42469/`);
console.log(`${PREFIX} Settings path: ${resolveSettingsPath()}`);
console.log(`${PREFIX} PDF cache directory: ${resolveCacheDir()}`);
console.log(
`${PREFIX} Data mode: ${
settings.useAnonymousData
? "anonymized sample"
: (settings.jsonPath ? `file (${settings.jsonPath})` : "embedded sample")
}`,
);
console.log(`${PREFIX} Documents loaded: ${documents.length}`);
if (loadError) console.error(`${PREFIX} ${loadError}`);
const clamped = clampWindowSize(settings);
console.log(
`${PREFIX} Restoring window size: ${clamped.windowWidth}x${clamped.windowHeight}`,
);
// Deno Desktop provides Deno.BrowserWindow at runtime but the standard
// Deno 2.9 type checker does not include desktop type declarations.
// The runtime `deno desktop` command also types this as a generic
// BrowserWindow<WindowBindings> without width/height properties.
// We use a local type assertion to narrow to the exact API we use.
// Remove this cast when official desktop types become available.
type _Win = {
readonly width: number;
readonly height: number;
onresize: ((event: Event) => void) | null;
onclose: ((event: Event) => void) | null;
show(): void;
};
const _WinCtor = (Deno as unknown as {
BrowserWindow: new (
opts?: { title?: string; width?: number; height?: number },
) => _Win;
}).BrowserWindow;
const win = new _WinCtor({
title: "BizMatch QC",
width: clamped.windowWidth,
height: clamped.windowHeight,
});
console.log(
`${PREFIX} Window initialized: ${clamped.windowWidth}x${clamped.windowHeight}`,
);
// Native resize tracking
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
try {
win.onresize = () => {
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
const w = win.width;
const h = win.height;
if (w > 0 && h > 0) {
settings.windowWidth = w;
settings.windowHeight = h;
console.log(`${PREFIX} Window resized: ${w}x${h}`);
saveAppSettings(settings).catch((err) =>
console.error(
`${PREFIX} Failed to save window size: ${errorMessage(err)}`,
)
);
}
}, 500);
};
win.onclose = () => {
if (resizeTimer !== undefined) clearTimeout(resizeTimer);
const w = win.width;
const h = win.height;
if (w > 0 && h > 0) {
settings.windowWidth = w;
settings.windowHeight = h;
syncSaveSettings();
}
};
} catch (err) {
console.warn(
`${PREFIX} Native window-event binding unavailable: ${errorMessage(err)}`,
);
console.warn(`${PREFIX} Falling back to web-page resize tracking.`);
}
win.show();