This commit is contained in:
2026-07-15 17:38:48 -05:00
parent 201f5c1b95
commit 2925f8aa35
14 changed files with 2099 additions and 159 deletions

View File

@@ -6,10 +6,19 @@ const normalize = (value: unknown) => text(value).toLocaleLowerCase();
export function validateDocuments(value: unknown): BuyerDocument[] {
if (!Array.isArray(value)) throw new Error("JSON root must be an array.");
return value.map((item, index) => {
if (!item || typeof item !== "object") throw new Error(`Record ${index + 1} is not an object.`);
if (!item || typeof item !== "object") {
throw new Error(`Record ${index + 1} is not an object.`);
}
const doc = item as Partial<BuyerDocument>;
if (!text(doc.file_name) || !text(doc.name_from_filename) || !text(doc._letter)) {
throw new Error(`Record ${index + 1} is missing file_name, name_from_filename, or _letter.`);
if (
!text(doc.file_name) || !text(doc.name_from_filename) ||
!text(doc._letter)
) {
throw new Error(
`Record ${
index + 1
} is missing file_name, name_from_filename, or _letter.`,
);
}
return doc as BuyerDocument;
});
@@ -27,19 +36,34 @@ export function groupDocuments(documents: BuyerDocument[]): PersonGroup[] {
return [...groups.entries()]
.map(([key, docs]) => {
docs.sort((a, b) => a.file_name.localeCompare(b.file_name));
const preferred = docs.find((d) => text(d.prospective_buyer))?.prospective_buyer;
const searchText = normalize([
const preferred = docs.find((d) => text(d.prospective_buyer))
?.prospective_buyer;
const searchText = normalize(
[
key,
preferred,
...docs.flatMap((
d,
) => [d.types_of_business_raw, d.notes_business_raw, d.address]),
].filter(Boolean).join("\n"),
);
return {
key,
preferred,
...docs.flatMap((d) => [d.types_of_business_raw, d.notes_business_raw, d.address]),
].filter(Boolean).join("\n"));
return { key, displayName: text(preferred) || key, documents: docs, searchText };
displayName: text(preferred) || key,
documents: docs,
searchText,
};
})
.sort((a, b) => a.key.localeCompare(b.key));
}
export function filterGroups(groups: PersonGroup[], query: string): PersonGroup[] {
export function filterGroups(
groups: PersonGroup[],
query: string,
): PersonGroup[] {
const terms = normalize(query).split(/\s+/).filter(Boolean);
if (!terms.length) return groups;
return groups.filter((group) => terms.every((term) => group.searchText.includes(term)));
return groups.filter((group) =>
terms.every((term) => group.searchText.includes(term))
);
}

View File

@@ -1,13 +1,22 @@
import { isAbsolute, join, normalize, relative } from "jsr:@std/path";
import { isAbsolute, join, normalize, relative } from "@std/path";
import type { BuyerDocument } from "./types.ts";
export function resolvePdfPath(baseDirectory: string, doc: BuyerDocument): string {
if (!baseDirectory.trim()) throw new Error("PDF base directory is not configured.");
if (!isAbsolute(baseDirectory)) throw new Error("PDF base directory must be absolute.");
export function resolvePdfPath(
baseDirectory: string,
doc: BuyerDocument,
): string {
if (!baseDirectory.trim()) {
throw new Error("PDF base directory is not configured.");
}
if (!isAbsolute(baseDirectory)) {
throw new Error("PDF base directory must be absolute.");
}
const base = normalize(baseDirectory);
const full = normalize(join(base, doc._letter, doc.file_name));
const rel = relative(base, full);
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Resolved PDF path escapes the base directory.");
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error("Resolved PDF path escapes the base directory.");
}
return full;
}

187
src/pdf_cache.ts Normal file
View File

@@ -0,0 +1,187 @@
import { join } from "@std/path";
import { resolveCacheDir } from "./settings.ts";
const PREFIX = "[BizMatch QC]";
interface CacheMeta {
sourcePath: string;
sourceSize: number;
sourceModified: number;
cachedAt: number;
}
async function sha256(input: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(input);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
}
export async function getCacheKey(sourcePath: string): Promise<string> {
return await sha256(sourcePath);
}
async function readMeta(metaPath: string): Promise<CacheMeta | null> {
try {
const content = await Deno.readTextFile(metaPath);
const meta = JSON.parse(content) as CacheMeta;
if (
!meta ||
typeof meta.sourcePath !== "string" ||
typeof meta.sourceSize !== "number"
) {
return null;
}
return meta;
} catch {
return null;
}
}
async function writeMeta(metaPath: string, meta: CacheMeta): Promise<void> {
const content = JSON.stringify(meta);
const tmp = `${metaPath}.tmp.${crypto.randomUUID()}`;
await Deno.writeTextFile(tmp, content);
try {
await Deno.rename(tmp, metaPath);
} catch {
await Deno.writeTextFile(metaPath, content);
try {
await Deno.remove(tmp);
} catch { /* ok */ }
}
}
export interface CacheResult {
path: string;
stale: boolean;
sourceError?: string;
}
export async function cacheOrGetPdf(sourcePath: string): Promise<CacheResult> {
const cacheDir = resolveCacheDir();
await Deno.mkdir(cacheDir, { recursive: true });
const cacheKey = await sha256(sourcePath);
const cachedFile = join(cacheDir, `${cacheKey}.pdf`);
const metaFile = join(cacheDir, `${cacheKey}.meta.json`);
let sourceStat: Deno.FileInfo | null = null;
const statStart = performance.now();
try {
sourceStat = await Deno.stat(sourcePath);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
const statMs = Math.round(performance.now() - statStart);
console.error(
`${PREFIX} PDF source stat failed (${statMs}ms): "${sourcePath}": ${msg}`,
);
const meta = await readMeta(metaFile);
if (!meta) {
throw new Error(`Cannot access PDF source "${sourcePath}": ${msg}`);
}
let cachedStat: Deno.FileInfo | null = null;
try {
cachedStat = await Deno.stat(cachedFile);
} catch { /* not found */ }
if (cachedStat && cachedStat.isFile) {
console.warn(
`${PREFIX} Serving stale cached PDF: ${cacheKey}`,
);
return { path: cachedFile, stale: true, sourceError: msg };
}
throw new Error(`Cannot access PDF source "${sourcePath}": ${msg}`);
}
if (!sourceStat.isFile) {
throw new Error(`"${sourcePath}" is not a file.`);
}
const sourceModified = sourceStat.mtime?.getTime() ?? 0;
const sourceSize = sourceStat.size;
const cachedStat = await Deno.stat(cachedFile).catch(() => null);
const meta = await readMeta(metaFile);
if (
cachedStat &&
meta &&
meta.sourcePath === sourcePath &&
meta.sourceSize === sourceSize &&
meta.sourceModified === sourceModified
) {
const lookupMs = Math.round(performance.now() - statStart);
console.log(
`${PREFIX} PDF cache hit: ${cacheKey}, lookup=${lookupMs}ms`,
);
return { path: cachedFile, stale: false };
}
if (meta) {
const lookupMs = Math.round(performance.now() - statStart);
console.log(
`${PREFIX} PDF cache refresh: ${cacheKey}, lookup=${lookupMs}ms`,
);
} else {
const lookupMs = Math.round(performance.now() - statStart);
console.log(
`${PREFIX} PDF cache miss: ${cacheKey}, lookup=${lookupMs}ms`,
);
}
const tmpFile = join(cacheDir, `${cacheKey}.pdf.tmp.${crypto.randomUUID()}`);
const copyStart = performance.now();
let sourceFile: Deno.FsFile | undefined;
let destFile: Deno.FsFile | undefined;
try {
sourceFile = await Deno.open(sourcePath, { read: true });
destFile = await Deno.open(tmpFile, { write: true, create: true });
await sourceFile.readable.pipeTo(destFile.writable);
} catch (error) {
try {
await Deno.remove(tmpFile);
} catch { /* ok */ }
const msg = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to copy PDF from "${sourcePath}": ${msg}`);
} finally {
try {
destFile?.close();
} catch { /* ok */ }
try {
sourceFile?.close();
} catch { /* ok */ }
}
try {
await Deno.rename(tmpFile, cachedFile);
} catch {
await Deno.copyFile(tmpFile, cachedFile);
try {
await Deno.remove(tmpFile);
} catch { /* ok */ }
}
const copyMs = Math.round(performance.now() - copyStart);
const sizeMb = sourceSize / (1024 * 1024);
console.log(
`${PREFIX} PDF copied to cache: ${cacheKey}, ${copyMs}ms, ${
sizeMb.toFixed(1)
} MB`,
);
const newMeta: CacheMeta = {
sourcePath,
sourceSize,
sourceModified,
cachedAt: Date.now(),
};
await writeMeta(metaFile, newMeta);
return { path: cachedFile, stale: false };
}

151
src/settings.ts Normal file
View File

@@ -0,0 +1,151 @@
import { dirname, join } from "@std/path";
export interface AppSettings {
jsonPath: string;
pdfBaseDirectory: string;
useAnonymousData: boolean;
windowWidth: number;
windowHeight: number;
}
const MIN_WIDTH = 1100;
const MIN_HEIGHT = 700;
export const DEFAULTS: AppSettings = {
jsonPath: "",
pdfBaseDirectory: "",
useAnonymousData: false,
windowWidth: 1500,
windowHeight: 950,
};
const PREFIX = "[BizMatch QC]";
export function resolveSettingsPath(): string {
if (Deno.build.os === "windows") {
const appData = Deno.env.get("APPDATA");
if (appData) return join(appData, "BizMatch QC", "settings.json");
const home = Deno.env.get("USERPROFILE") || "C:\\";
return join(home, "AppData", "Roaming", "BizMatch QC", "settings.json");
}
const xdg = Deno.env.get("XDG_CONFIG_HOME");
if (xdg) return join(xdg, "bizmatch-qc", "settings.json");
const home = Deno.env.get("HOME") || "";
return join(home, ".config", "bizmatch-qc", "settings.json");
}
export function resolveCacheDir(): string {
if (Deno.build.os === "windows") {
const localAppData = Deno.env.get("LOCALAPPDATA");
if (localAppData) return join(localAppData, "BizMatch QC", "pdf-cache");
const home = Deno.env.get("USERPROFILE") || "C:\\";
return join(home, "AppData", "Local", "BizMatch QC", "pdf-cache");
}
const xdg = Deno.env.get("XDG_CACHE_HOME");
if (xdg) return join(xdg, "bizmatch-qc", "pdfs");
const home = Deno.env.get("HOME") || "";
return join(home, ".cache", "bizmatch-qc", "pdfs");
}
export function validateSettings(raw: unknown): AppSettings {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return { ...DEFAULTS };
}
const obj = raw as Record<string, unknown>;
const settings: AppSettings = { ...DEFAULTS };
if (typeof obj.jsonPath === "string") settings.jsonPath = obj.jsonPath;
if (typeof obj.pdfBaseDirectory === "string") {
settings.pdfBaseDirectory = obj.pdfBaseDirectory;
}
if (typeof obj.useAnonymousData === "boolean") {
settings.useAnonymousData = obj.useAnonymousData;
}
if (
typeof obj.windowWidth === "number" && !isNaN(obj.windowWidth) &&
obj.windowWidth >= MIN_WIDTH
) {
settings.windowWidth = obj.windowWidth;
}
if (
typeof obj.windowHeight === "number" && !isNaN(obj.windowHeight) &&
obj.windowHeight >= MIN_HEIGHT
) {
settings.windowHeight = obj.windowHeight;
}
return settings;
}
export async function loadSettings(): Promise<AppSettings> {
const path = resolveSettingsPath();
console.log(`${PREFIX} Settings path: ${path}`);
try {
const content = await Deno.readTextFile(path);
const parsed = JSON.parse(content);
const settings = validateSettings(parsed);
console.log(`${PREFIX} Settings loaded successfully.`);
return settings;
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
console.log(`${PREFIX} No existing settings file found. Using defaults.`);
} else {
const msg = error instanceof Error ? error.message : String(error);
console.error(`${PREFIX} Settings load failed: ${msg}. Using defaults.`);
}
return { ...DEFAULTS };
}
}
export async function saveSettings(settings: AppSettings): Promise<void> {
const path = resolveSettingsPath();
const dir = dirname(path);
await Deno.mkdir(dir, { recursive: true });
const tmp = `${path}.tmp.${crypto.randomUUID()}`;
const content = JSON.stringify(settings, null, 2);
await Deno.writeTextFile(tmp, content);
try {
await Deno.rename(tmp, path);
} catch {
await Deno.writeTextFile(path, content);
try {
await Deno.remove(tmp);
} catch { /* ok */ }
}
}
export async function readLegacyConfig(): Promise<
{ jsonPath: string; pdfBaseDirectory: string } | null
> {
const legacyPath = join(Deno.cwd(), ".bizmatch-qc.json");
try {
const content = await Deno.readTextFile(legacyPath);
const parsed = JSON.parse(content);
if (!parsed || typeof parsed !== "object") return null;
const legacy = parsed as Record<string, unknown>;
console.log(`${PREFIX} Migrating legacy config from: ${legacyPath}`);
return {
jsonPath: typeof legacy.jsonPath === "string" ? legacy.jsonPath : "",
pdfBaseDirectory: typeof legacy.pdfBaseDirectory === "string"
? legacy.pdfBaseDirectory
: "",
};
} catch {
return null;
}
}
export function clampWindowSize(settings: AppSettings): AppSettings {
return {
...settings,
windowWidth: Math.max(
MIN_WIDTH,
settings.windowWidth || DEFAULTS.windowWidth,
),
windowHeight: Math.max(
MIN_HEIGHT,
settings.windowHeight || DEFAULTS.windowHeight,
),
};
}