update
This commit is contained in:
46
src/data.ts
46
src/data.ts
@@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
19
src/paths.ts
19
src/paths.ts
@@ -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
187
src/pdf_cache.ts
Normal 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
151
src/settings.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user