update
This commit is contained in:
435
main.ts
435
main.ts
@@ -1,60 +1,114 @@
|
||||
import { dirname, fromFileUrl, join } from "jsr:@std/path";
|
||||
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" };
|
||||
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");
|
||||
const PREFIX = "[BizMatch QC]";
|
||||
|
||||
type Config = { jsonPath: string; pdfBaseDirectory: string };
|
||||
type LoadResult = { documents: BuyerDocument[]; source: "sample" | "file" };
|
||||
|
||||
let config: Config = await readConfig();
|
||||
let settings: AppSettings = await initializeSettings();
|
||||
let documents: BuyerDocument[] = [];
|
||||
let loadError = "";
|
||||
let dataSource: "sample" | "file" = "sample";
|
||||
|
||||
try {
|
||||
const loaded = await loadDocuments(config.jsonPath);
|
||||
const loaded = await loadDocumentsForMode();
|
||||
documents = loaded.documents;
|
||||
dataSource = loaded.source;
|
||||
} catch (error) {
|
||||
loadError = errorMessage(error);
|
||||
console.error(`[BizMatch QC] Initial data load failed: ${loadError}`);
|
||||
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 readConfig(): Promise<Config> {
|
||||
try {
|
||||
return {
|
||||
jsonPath: "",
|
||||
pdfBaseDirectory: "",
|
||||
...JSON.parse(await Deno.readTextFile(CONFIG_PATH)),
|
||||
};
|
||||
} catch {
|
||||
return { jsonPath: "", pdfBaseDirectory: "" };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocuments(jsonPath: string): Promise<LoadResult> {
|
||||
if (!jsonPath.trim()) {
|
||||
return { documents: validateDocuments(SAMPLE_DOCUMENTS), source: "sample" };
|
||||
}
|
||||
|
||||
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)}`);
|
||||
throw new Error(
|
||||
`Cannot read JSON file "${jsonPath}": ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
@@ -67,36 +121,94 @@ async function loadDocuments(jsonPath: string): Promise<LoadResult> {
|
||||
try {
|
||||
return { documents: validateDocuments(parsed), source: "file" };
|
||||
} catch (error) {
|
||||
throw new Error(`JSON validation failed for "${jsonPath}": ${errorMessage(error)}`);
|
||||
throw new Error(
|
||||
`JSON validation failed for "${jsonPath}": ${errorMessage(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(next: Config): Promise<void> {
|
||||
const normalized: Config = {
|
||||
jsonPath: next.jsonPath.trim(),
|
||||
pdfBaseDirectory: next.pdfBaseDirectory.trim(),
|
||||
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,
|
||||
};
|
||||
|
||||
// Load first. Never replace valid in-memory data with an empty list on failure.
|
||||
const loaded = await loadDocuments(normalized.jsonPath);
|
||||
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 (normalized.pdfBaseDirectory) {
|
||||
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(normalized.pdfBaseDirectory);
|
||||
if (!stat.isDirectory) throw new Error("Path exists but is not a directory.");
|
||||
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 "${normalized.pdfBaseDirectory}": ${errorMessage(error)}`);
|
||||
throw new Error(
|
||||
`Cannot access PDF base directory "${newSettings.pdfBaseDirectory}": ${
|
||||
errorMessage(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Deno.writeTextFile(CONFIG_PATH, JSON.stringify(normalized, null, 2));
|
||||
config = normalized;
|
||||
await saveAppSettings(newSettings);
|
||||
settings = newSettings;
|
||||
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}`);
|
||||
console.log(
|
||||
`${PREFIX} Loaded ${documents.length} documents from ${settings.jsonPath}.`,
|
||||
);
|
||||
if (settings.pdfBaseDirectory) {
|
||||
console.log(`${PREFIX} PDF base directory: ${settings.pdfBaseDirectory}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,29 +233,72 @@ function staticFile(pathname: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
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") {
|
||||
return json({ config, documents, loadError, dataSource });
|
||||
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 {
|
||||
const body = await request.json() as Config;
|
||||
await saveConfig({
|
||||
jsonPath: body.jsonPath ?? "",
|
||||
pdfBaseDirectory: body.pdfBaseDirectory ?? "",
|
||||
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(`[BizMatch QC] Settings rejected: ${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];
|
||||
@@ -152,21 +307,97 @@ Deno.serve(async (request) => {
|
||||
}
|
||||
|
||||
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 });
|
||||
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(stat.size),
|
||||
"content-disposition": `inline; filename*=UTF-8''${encodeURIComponent(doc.file_name)}`,
|
||||
"cache-control": "no-store",
|
||||
"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(`[BizMatch QC] PDF open failed for "${doc.file_name}": ${message}`);
|
||||
console.error(
|
||||
`${PREFIX} PDF open failed for "${doc.file_name}": ${message}`,
|
||||
);
|
||||
return json({ error: message }, 404);
|
||||
}
|
||||
}
|
||||
@@ -174,15 +405,87 @@ Deno.serve(async (request) => {
|
||||
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}`);
|
||||
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 win = new Deno.BrowserWindow({
|
||||
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: 1500,
|
||||
height: 950,
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user