diff --git a/finances/calculate_file_names.ts b/finances/calculate_file_names.ts new file mode 100644 index 0000000..f659040 --- /dev/null +++ b/finances/calculate_file_names.ts @@ -0,0 +1,132 @@ +import { isReadPdfError, readPdf } from "./pdf_read.ts"; +import { isDocumentParseError } from "./document.ts"; +import { identify, isIdentifyError } from "./identify.ts"; + +export interface CalculateFileNamesError { + type: "CalculateFileNamesError"; + message: string; + filePath: string; +} + +export function isCalculateFileNamesError( + e: unknown, +): e is CalculateFileNamesError { + return ( + e !== null && + typeof e === "object" && + "type" in e && + e.type === "CalculateFileNamesError" && + "message" in e && + typeof e.message === "string" && + "filePath" in e && + typeof e.filePath === "string" + ); +} + +export async function calculateFileNames( + filePaths: string[], +): Promise | CalculateFileNamesError> { + const infoByFileName = new Map< + string, + Array<{ filePath: string; hash: string }> + >(); + const fileNameByFilePath = new Map(); + + for (const filePath of filePaths) { + if (fileNameByFilePath.has(filePath)) { + continue; + } + + const readPdfResult = await readPdf(filePath); + if (isReadPdfError(readPdfResult)) { + return { + type: "CalculateFileNamesError", + message: readPdfResult.message, + filePath, + }; + } + + const document = identify(readPdfResult.lines); + if (typeof document === "undefined") { + return { + type: "CalculateFileNamesError", + message: "unable to identify pdf contents", + filePath, + }; + } + if (isIdentifyError(document)) { + return { + type: "CalculateFileNamesError", + message: document.message, + filePath, + }; + } + + const parseResult = document.parse(readPdfResult.lines); + if (isDocumentParseError(parseResult)) { + return { + type: "CalculateFileNamesError", + message: parseResult.message, + filePath, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fileName = document.calculateFileName(parseResult as unknown as any); + fileNameByFilePath.set(filePath, fileName); + + const newFileNameInfo = { filePath, hash: readPdfResult.hash }; + const info = infoByFileName.get(fileName); + if (info) { + info.push(newFileNameInfo); + } else { + infoByFileName.set(fileName, [newFileNameInfo]); + } + } + + for (const [fileName, infoList] of infoByFileName.entries()) { + const hashes = new Set(); + for (const info of infoList) { + hashes.add(info.hash); + } + + if (hashes.size < 2) { + continue; + } + + const numberByHash = new Map(); + for (const hash of hashes) { + numberByHash.set(hash, numberByHash.size + 1); + } + + const fileNameByHash = new Map(); + for (const [hash, fileNumber] of numberByHash) { + const lastPeriodIndex = fileName.lastIndexOf("."); + let numberedFileName: string; + if (lastPeriodIndex < 0) { + numberedFileName = `${fileName} ${fileNumber}`; + } else { + numberedFileName = + fileName.substring(0, lastPeriodIndex) + + ` ${fileNumber}` + + fileName.substring(lastPeriodIndex); + } + fileNameByHash.set(hash, numberedFileName); + } + + for (const info of infoList) { + const newFileName = fileNameByHash.get(info.hash); + if (!newFileName) { + throw new Error( + `internal error kwteqzzx79: ` + + `fileNameByHash.get(info.hash) ` + + `returned ${Bun.inspect(newFileName)}`, + ); + } + + fileNameByFilePath.set(info.filePath, newFileName); + } + } + + return fileNameByFilePath; +} diff --git a/finances/commands/filename.ts b/finances/commands/filename.ts new file mode 100644 index 0000000..1dede26 --- /dev/null +++ b/finances/commands/filename.ts @@ -0,0 +1,41 @@ +import { + calculateFileNames, + isCalculateFileNamesError, +} from "../calculate_file_names.ts"; + +export interface FilenameOptions { + v?: boolean; +} + +export async function filenameCommand( + filePaths: string | string[], + options?: FilenameOptions, +): Promise { + if (typeof filePaths === "string") { + return filenameCommand([filePaths]); + } + + const outputFileNameByFilePath = await calculateFileNames(filePaths); + if (isCalculateFileNamesError(outputFileNameByFilePath)) { + const { message, filePath } = outputFileNameByFilePath; + console.error(`ERROR: ${message}: ${filePath}`); + process.exit(1); + } + + for (const filePath of filePaths) { + const outputFileName = outputFileNameByFilePath.get(filePath); + if (!outputFileName) { + throw new Error( + `internal error patvap56xt: ` + + `outputFileNameByFilePath.get(${Bun.inspect(filePath)}) ` + + `returned ${Bun.inspect(outputFileName)}`, + ); + } + + if (options?.v) { + console.log(`${filePath}: ${outputFileName}`); + } else { + console.log(outputFileName); + } + } +} diff --git a/finances/commands/identify.ts b/finances/commands/identify.ts new file mode 100644 index 0000000..8257c6d --- /dev/null +++ b/finances/commands/identify.ts @@ -0,0 +1,39 @@ +import { isReadPdfError, readPdf } from "../pdf_read.ts"; +import { identify, isIdentifyError } from "../identify.ts"; + +export interface IdentifyOptions { + v?: boolean; +} + +export async function identifyCommand( + filePath: string | string[], + options?: IdentifyOptions, +): Promise { + if (Array.isArray(filePath)) { + for (const currentFilePath of filePath) { + await identifyCommand(currentFilePath, options); + } + return; + } + + const readPdfResult = await readPdf(filePath); + if (isReadPdfError(readPdfResult)) { + console.error(`ERROR: ${readPdfResult.message}: ${filePath}`); + process.exit(1); + } + + const document = identify(readPdfResult.lines); + if (isIdentifyError(document)) { + const { message } = document; + console.error(`ERROR: ${message}: ${filePath}`); + process.exit(1); + } + + const type: string = document?.type ?? ""; + + if (options?.v) { + console.log(`${filePath}: ${type}`); + } else { + console.log(type); + } +} diff --git a/finances/commands/parse.ts b/finances/commands/parse.ts new file mode 100644 index 0000000..a9e9e92 --- /dev/null +++ b/finances/commands/parse.ts @@ -0,0 +1,48 @@ +import { isReadPdfError, readPdf } from "../pdf_read.ts"; +import { isDocumentParseError } from "../document.ts"; +import { identify, isIdentifyError } from "../identify.ts"; + +export interface ParseOptions { + v?: boolean; +} + +export async function parseCommand( + filePath: string | string[], + options?: ParseOptions, +): Promise { + if (Array.isArray(filePath)) { + for (const currentFilePath of filePath) { + await parseCommand(currentFilePath, options); + } + return; + } + + const readPdfResult = await readPdf(filePath); + if (isReadPdfError(readPdfResult)) { + console.error(`ERROR: ${readPdfResult.message}: ${filePath}`); + process.exit(1); + } + + const document = identify(readPdfResult.lines); + if (typeof document === "undefined") { + console.error(`ERROR: unable to identify pdf contents: ${filePath}`); + process.exit(1); + } + if (isIdentifyError(document)) { + const { message } = document; + console.error(`ERROR: ${message}: ${filePath}`); + process.exit(1); + } + + const parseResult = document.parse(readPdfResult.lines); + if (isDocumentParseError(parseResult)) { + const { message } = parseResult; + console.error(`ERROR: ${message}: ${filePath}`); + process.exit(1); + } + + if (options?.v) { + console.log(filePath); + } + console.log(parseResult); +} diff --git a/finances/commands/print.ts b/finances/commands/print.ts new file mode 100644 index 0000000..50a3796 --- /dev/null +++ b/finances/commands/print.ts @@ -0,0 +1,28 @@ +import { isReadPdfError, readPdf } from "../pdf_read.ts"; + +export interface PrintOptions { + v?: boolean; +} + +export async function printCommand( + filePath: string | string[], + options?: PrintOptions, +): Promise { + if (Array.isArray(filePath)) { + for (const currentFilePath of filePath) { + await printCommand(currentFilePath, options); + } + return; + } + + const readPdfResult = await readPdf(filePath); + if (isReadPdfError(readPdfResult)) { + console.error(`ERROR: ${readPdfResult.message}: ${filePath}`); + process.exit(1); + } + + if (options?.v) { + console.log(filePath); + } + console.log(readPdfResult.text); +} diff --git a/finances/commands/rename.ts b/finances/commands/rename.ts new file mode 100644 index 0000000..037baa9 --- /dev/null +++ b/finances/commands/rename.ts @@ -0,0 +1,72 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +import { + calculateFileNames, + isCalculateFileNamesError, +} from "../calculate_file_names.ts"; + +export interface RenameOptions { + v?: boolean; +} + +export async function renameCommand( + filePaths: string | string[], + options?: RenameOptions, +): Promise { + if (typeof filePaths === "string") { + return renameCommand([filePaths]); + } + + const outputFileNameByFilePath = await calculateFileNames(filePaths); + if (isCalculateFileNamesError(outputFileNameByFilePath)) { + const { message, filePath } = outputFileNameByFilePath; + console.error(`ERROR: ${message}: ${filePath}`); + process.exit(1); + } + + const renamedPaths = new Set(); + let skippedCount = 0; + + for (const srcFilePath of filePaths) { + const destFileName = outputFileNameByFilePath.get(srcFilePath); + if (!destFileName) { + throw new Error( + `internal error xwttprzh98: ` + + `outputFileNameByFilePath.get(${Bun.inspect(srcFilePath)}) ` + + `returned ${Bun.inspect(destFileName)}`, + ); + } + + if (renamedPaths.has(srcFilePath)) { + console.log( + `Skipping renaming ${srcFilePath} to ${destFileName} (already processed)`, + ); + skippedCount++; + continue; + } + + const srcFileName = path.basename(srcFilePath); + if (srcFileName === destFileName) { + console.log( + `Skipping renaming ${srcFilePath} (already has the correct file name)`, + ); + skippedCount++; + continue; + } + + const dir = path.dirname(srcFilePath); + const destFilePath = path.join(dir, destFileName); + + if (options?.v) { + console.log(`Renaming ${srcFilePath} to ${destFileName}`); + } else { + console.log(destFilePath); + } + + await fs.rename(srcFilePath, destFilePath); + renamedPaths.add(srcFilePath); + } + + console.log(`${renamedPaths.size} files renamed (${skippedCount} skipped)`); +} diff --git a/finances/date.ts b/finances/date.ts new file mode 100644 index 0000000..94780c5 --- /dev/null +++ b/finances/date.ts @@ -0,0 +1,36 @@ +import { parse as tempoParse, format as tempoFormat } from "@formkit/tempo"; +import { messageForError } from "./error.ts"; + +export interface ParseDateError { + type: "ParseDateError"; + message: string; +} + +export function isParseDateError(e: unknown): e is ParseDateError { + return ( + e !== null && + typeof e === "object" && + "type" in e && + e.type === "ParseDateError" && + "message" in e && + typeof e.message === "string" + ); +} + +export function parseDateToYYYYMMDD( + format: string, + text: string, +): string | ParseDateError { + let parsedDate: Date; + try { + parsedDate = tempoParse(text, format, "en"); + } catch (e: unknown) { + return { type: "ParseDateError", message: messageForError(e) }; + } + + if (isNaN(parsedDate.getTime())) { + return { type: "ParseDateError", message: "invalid date" }; + } + + return tempoFormat(parsedDate, "YYYY-MM-DD"); +} diff --git a/finances/document.ts b/finances/document.ts new file mode 100644 index 0000000..89f3ddd --- /dev/null +++ b/finances/document.ts @@ -0,0 +1,32 @@ +export interface DocumentParseError { + type: "DocumentParseError"; + message: string; +} + +export function isDocumentParseError(e: unknown): e is DocumentParseError { + return ( + e !== null && + typeof e === "object" && + "type" in e && + e.type === "DocumentParseError" && + "message" in e && + typeof e.message === "string" + ); +} + +export interface ParsedDocument { + readonly type: TypeName; +} + +export interface Document< + ParsedDocumentT extends ParsedDocument, + TypeName extends string, +> { + readonly type: TypeName; + + identify(lines: readonly string[]): boolean; + + parse(lines: readonly string[]): ParsedDocumentT | DocumentParseError; + + calculateFileName(pdf: Readonly): string; +} diff --git a/finances/documents.ts b/finances/documents.ts new file mode 100644 index 0000000..8625d7b --- /dev/null +++ b/finances/documents.ts @@ -0,0 +1,17 @@ +import { rogersBill } from "./documents/rogers.ts"; +import { publicMobileStatement } from "./documents/public_mobile.ts"; +import { + questradeMarginStatement, + questradeRESPStatement, + questradeRRSPStatement, +} from "./documents/questrade.ts"; + +export const allDocuments = Object.freeze([ + publicMobileStatement, + rogersBill, + questradeMarginStatement, + questradeRESPStatement, + questradeRRSPStatement, +]); + +export type Documents = Exclude<(typeof allDocuments)[number], undefined>; diff --git a/finances/documents/public_mobile.ts b/finances/documents/public_mobile.ts new file mode 100644 index 0000000..01c0058 --- /dev/null +++ b/finances/documents/public_mobile.ts @@ -0,0 +1,68 @@ +import { type Document, type DocumentParseError } from "../document.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "../date.ts"; + +export interface ParsedPublicMobileStatement { + type: "PublicMobileStatement"; + invoiceDate: string; + totalAmountPaid: string; +} + +class PublicMobileStatement implements Document< + ParsedPublicMobileStatement, + "PublicMobileStatement" +> { + readonly type = "PublicMobileStatement" as const; + + identify(lines: readonly string[]): boolean { + return lines.includes("Public Mobile Account"); + } + + calculateFileName(pdf: Readonly): string { + const { invoiceDate, totalAmountPaid } = pdf; + return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; + } + + parse( + lines: readonly string[], + ): ParsedPublicMobileStatement | DocumentParseError { + const invoiceIndex = lines.findIndex( + (line) => line.toLowerCase() === "invoice", + ); + if (invoiceIndex < 0) { + return { type: "DocumentParseError", message: "INVOICE line not found" }; + } + const invoiceDateStr = lines[invoiceIndex + 1]?.trim(); + if (!invoiceDateStr) { + return { + type: "DocumentParseError", + message: "expected line after INVOICE line", + }; + } + const invoiceDate = parseDateToYYYYMMDD("MMM D, YYYY", invoiceDateStr); + if (isParseDateError(invoiceDate)) { + const { message } = invoiceDate; + return { + type: "DocumentParseError", + message: `unable to parse invoice date: ${invoiceDateStr} (${message})`, + }; + } + + const totalAmountPaidLine = lines.find((line) => + line.toLowerCase().startsWith("total amount paid"), + ); + if (!totalAmountPaidLine) { + return { + type: "DocumentParseError", + message: "Total Amount Paid line not found", + }; + } + + const totalAmountPaid = totalAmountPaidLine.substring(17).trim(); + + return { type: "PublicMobileStatement", invoiceDate, totalAmountPaid }; + } +} + +export const publicMobileStatement: Readonly< + Document +> = Object.freeze(new PublicMobileStatement()); diff --git a/finances/documents/questrade.ts b/finances/documents/questrade.ts new file mode 100644 index 0000000..33c579a --- /dev/null +++ b/finances/documents/questrade.ts @@ -0,0 +1,168 @@ +import { type Document, type DocumentParseError } from "../document.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "../date.ts"; + +export type StatementType = + | "QuestradeRESPStatement" + | "QuestradeRRSPStatement" + | "QuestradeMarginStatement"; + +export function isStatementType(value: unknown): value is StatementType { + return ( + value === "QuestradeRESPStatement" || + value === "QuestradeRRSPStatement" || + value === "QuestradeMarginStatement" + ); +} + +export interface ParsedQuestradeStatement { + type: Type; + statementDate: string; + accountNumber: string; + balance: string; +} + +export type ParsedQuestradeRESPStatement = + ParsedQuestradeStatement<"QuestradeRESPStatement">; +export type ParsedQuestradeRRSPStatement = + ParsedQuestradeStatement<"QuestradeRRSPStatement">; +export type ParsedQuestradeMarginStatement = + ParsedQuestradeStatement<"QuestradeMarginStatement">; + +class QuestradeStatement implements Document< + ParsedQuestradeStatement, + Type +> { + readonly type: Type; + readonly #identifyingLine: string; + readonly #fileNameType: string; + + constructor(type: Type, identifyingLine: string, fileNameType: string) { + this.type = type; + this.#identifyingLine = identifyingLine.toLowerCase(); + this.#fileNameType = fileNameType; + } + + identify(lines: readonly string[]): boolean { + if ( + !lines.some((line) => + line.toLowerCase().startsWith("questrade wealth management inc."), + ) + ) { + return false; + } + + if ( + lines.some((line) => line.toLowerCase().includes(this.#identifyingLine)) + ) { + return true; + } + + return false; + } + + calculateFileName(pdf: Readonly>): string { + const { statementDate, accountNumber, balance } = pdf; + return ( + `${statementDate} Questrade ${this.#fileNameType} ` + + `${accountNumber} Statement ${balance}.pdf` + ); + } + + parse( + lines: readonly string[], + ): ParsedQuestradeStatement | DocumentParseError { + const accountNumberRegex = /Account\s*#:\s*(\d+)/i; + const accountNumberLine = lines.find((line) => + accountNumberRegex.test(line), + ); + if (!accountNumberLine) { + return { + type: "DocumentParseError", + message: "Account number line not found", + }; + } + const accountNumber = accountNumberLine.match(accountNumberRegex)?.[1]; + if (!accountNumber) { + throw new Error( + "internal error rhtan4myg2: accountNumberRegex should have matched", + ); + } + + const currentMonthRegex = /Current month\s*:\s*(\w+\s+\d+,\s*\d+)/i; + const currentMonthLine = lines.find((line) => + line.match(currentMonthRegex), + ); + if (!currentMonthLine) { + return { + type: "DocumentParseError", + message: "Current month line not found", + }; + } + const statementDateStr = currentMonthLine.match(currentMonthRegex)?.[1]; + if (!statementDateStr) { + throw new Error( + "internal error ydjbyakqr8: currentMonthRegex should have matched", + ); + } + const statementDate = parseDateToYYYYMMDD("MMMM D, YYYY", statementDateStr); + if (isParseDateError(statementDate)) { + const { message } = statementDate; + return { + type: "DocumentParseError", + message: `unable to parse statement date: ${statementDateStr} (${message})`, + }; + } + + const balanceRegex = /Current month balance:\s*(\$[\d,.]+)/i; + const balanceLine = lines.find((line) => line.match(balanceRegex)); + if (!balanceLine) { + return { type: "DocumentParseError", message: "Balance line not found" }; + } + const balance = balanceLine.match(balanceRegex)?.[1]; + if (!balance) { + throw new Error( + "internal error ky4fmdxh8b: balanceRegex should have matched", + ); + } + + return { type: this.type, statementDate, accountNumber, balance }; + } +} + +class QuestradeRESPStatement extends QuestradeStatement<"QuestradeRESPStatement"> { + constructor() { + super("QuestradeRESPStatement", "(RESP)", "RESP"); + } +} + +class QuestradeRRSPStatement extends QuestradeStatement<"QuestradeRRSPStatement"> { + constructor() { + super( + "QuestradeRRSPStatement", + "registered retirement savings plan", + "RRSP", + ); + } +} + +class QuestradeMarginStatement extends QuestradeStatement<"QuestradeMarginStatement"> { + constructor() { + super( + "QuestradeMarginStatement", + "individual margin account", + "Margin Account", + ); + } +} + +export const questradeRESPStatement: Readonly< + Document +> = Object.freeze(new QuestradeRESPStatement()); + +export const questradeRRSPStatement: Readonly< + Document +> = Object.freeze(new QuestradeRRSPStatement()); + +export const questradeMarginStatement: Readonly< + Document +> = Object.freeze(new QuestradeMarginStatement()); diff --git a/finances/documents/rogers.ts b/finances/documents/rogers.ts new file mode 100644 index 0000000..67fd071 --- /dev/null +++ b/finances/documents/rogers.ts @@ -0,0 +1,70 @@ +import { type Document, type DocumentParseError } from "../document.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "../date.ts"; + +export interface ParsedRogersBill { + type: "RogersBill"; + billDate: string; + amountDue: string; +} + +class RogersBill implements Document { + readonly type = "RogersBill" as const; + + identify(lines: readonly string[]): boolean { + return lines.some((line) => line.toUpperCase().includes("1-888-ROGERS-1")); + } + + calculateFileName(pdf: Readonly): string { + const { billDate, amountDue } = pdf; + return `${billDate} Rogers Bill ${amountDue}.pdf`; + } + + parse(lines: readonly string[]): ParsedRogersBill | DocumentParseError { + const amountDueIndex = lines.findIndex( + (line) => line.toLowerCase() === "what is the total due?", + ); + if (amountDueIndex < 0) { + return { + type: "DocumentParseError", + message: "amount due line not found", + }; + } + const amountDue = lines[amountDueIndex + 1]?.trim(); + if (!amountDue) { + return { + type: "DocumentParseError", + message: "expected line after amount due line", + }; + } + + const billDateIndex = lines.findIndex( + (line) => line.toLowerCase() === "bill date", + ); + if (billDateIndex < 0) { + return { + type: "DocumentParseError", + message: "bill date line not found", + }; + } + const billDateStr = lines[billDateIndex + 1]?.trim(); + if (!billDateStr) { + return { + type: "DocumentParseError", + message: "expected line after bill date line", + }; + } + const billDate = parseDateToYYYYMMDD("MMM D, YYYY", billDateStr); + if (isParseDateError(billDate)) { + const { message } = billDate; + return { + type: "DocumentParseError", + message: `unable to parse invoice date: ${billDateStr} (${message})`, + }; + } + + return { type: "RogersBill", billDate, amountDue }; + } +} + +export const rogersBill: Readonly> = + Object.freeze(new RogersBill()); diff --git a/finances/error.ts b/finances/error.ts new file mode 100644 index 0000000..cee984f --- /dev/null +++ b/finances/error.ts @@ -0,0 +1,19 @@ +export function messageForError(e: unknown): string { + const code = propertyFromUnknown(e, "code"); + const message = propertyFromUnknown(e, "message"); + if (code === "ENOENT") { + return "file not found"; + } else if (code === "EACCES") { + return "insufficient permissions to read file"; + } else if (typeof message === "string" && message.trim().length > 0) { + return message.trim(); + } else { + return `unknown error (${Bun.inspect(e)})`; + } +} + +function propertyFromUnknown(obj: unknown, propertyName: string): unknown { + return typeof obj === "object" && obj !== null && propertyName in obj + ? (obj as Record)[propertyName] + : undefined; +} diff --git a/finances/identify.ts b/finances/identify.ts new file mode 100644 index 0000000..49a04d7 --- /dev/null +++ b/finances/identify.ts @@ -0,0 +1,38 @@ +import { allDocuments, type Documents } from "./documents.ts"; + +export interface IdentifyError { + type: "IdentifyError"; + message: string; +} + +export function isIdentifyError(e: unknown): e is IdentifyError { + return ( + e !== null && + typeof e === "object" && + "type" in e && + e.type === "IdentifyError" && + "message" in e && + typeof e.message === "string" + ); +} + +export function identify( + lines: readonly string[], +): Documents | IdentifyError | undefined { + const filteredDocuments = allDocuments.filter((document) => + document.identify(lines), + ); + + if (filteredDocuments.length > 1) { + const types = filteredDocuments.map((document) => document.type); + const typesStr = types.sort().join(); + return { + type: "IdentifyError", + message: + `Unable to uniquely identify the PDF type; ` + + `the PDF matched ${filteredDocuments.length} types: ${typesStr}`, + }; + } + + return filteredDocuments[0]; +} diff --git a/finances/index.ts b/finances/index.ts new file mode 100644 index 0000000..43b2259 --- /dev/null +++ b/finances/index.ts @@ -0,0 +1,56 @@ +import { Command } from "commander"; +import { printCommand } from "./commands/print"; +import { identifyCommand } from "./commands/identify"; +import { parseCommand } from "./commands/parse"; +import { filenameCommand } from "./commands/filename"; +import { renameCommand } from "./commands/rename"; + +const program = new Command(); + +program + .description( + "Reads PDF file and extracts information " + + "relevant for financial record keeping", + ) + .version("1.0.0"); + +program + .command("print") + .description("Reads PDF files and prints their text to stdout") + .argument("", "paths of the PDF files") + .option("-v", "print the file path on its own line before its contents") + .action(printCommand); + +program + .command("identify") + .description("Reads PDF files and prints their types to stdout") + .argument("", "paths of the PDF files") + .option("-v", "prefix each line with the file path") + .action(identifyCommand); + +program + .command("parse") + .description( + "Reads PDF files, parses their content, and prints them to stdout", + ) + .argument("", "paths of the PDF files") + .option("-v", "print the file path on its own line before its contents") + .action(parseCommand); + +program + .command("filename") + .description( + "Reads PDF files and prints their normalized file names to stdout", + ) + .argument("", "paths of the PDF files") + .option("-v", "prefix each line with the file path") + .action(filenameCommand); + +program + .command("rename") + .description("Renames PDF files to their normalized file names") + .argument("", "paths of the PDF files") + .option("-v", "prefix each output path with the source path") + .action(renameCommand); + +program.parse(process.argv); diff --git a/finances/pdf_read.ts b/finances/pdf_read.ts new file mode 100644 index 0000000..0a8d327 --- /dev/null +++ b/finances/pdf_read.ts @@ -0,0 +1,55 @@ +import * as fs from "node:fs/promises"; +import { PDFParse } from "pdf-parse"; + +import { messageForError } from "./error.ts"; + +export interface ReadPdfError { + type: "ReadPdfError"; + message: string; +} + +export function isReadPdfError(e: unknown): e is ReadPdfError { + return ( + e !== null && + typeof e === "object" && + "type" in e && + e.type === "ReadPdfError" && + "message" in e && + typeof e.message === "string" + ); +} + +export interface ReadPdfResult { + text: string; + lines: string[]; + hash: string; +} + +export async function readPdf( + filePath: string, +): Promise { + let fileContents: Buffer; + try { + fileContents = await fs.readFile(filePath); + } catch (e: unknown) { + return { type: "ReadPdfError", message: messageForError(e) }; + } + + const parser = new PDFParse({ data: fileContents }); + + let text: string; + try { + const textContents = await parser.getText(); + text = textContents.text; + } catch (e: unknown) { + const errorMessage = messageForError(e); + const message = `parsing pdf file contents failed (${errorMessage})`; + return { type: "ReadPdfError", message }; + } finally { + await parser.destroy(); + } + + const lines = text.split("\n").map((line) => line.trim()); + const hash = Bun.CryptoHasher.hash("sha512-256", fileContents, "hex"); + return { text, lines, hash }; +} diff --git a/finances/stmt.ts b/finances/stmt.ts deleted file mode 100644 index affba6a..0000000 --- a/finances/stmt.ts +++ /dev/null @@ -1,704 +0,0 @@ -import * as fs from "node:fs/promises"; -import { Command } from "commander"; -import { PDFParse } from "pdf-parse"; -import { parse as tempoParse, format as tempoFormat } from "@formkit/tempo"; -import * as path from "node:path"; - -const program = new Command(); - -function unreachable(value: never, message: string): never { - throw new Error(`should never get here: ${message} (${Bun.inspect(value)})`); -} - -function propertyFromUnknown(obj: unknown, propertyName: string): unknown { - return typeof obj === "object" && obj !== null && propertyName in obj - ? (obj as Record)[propertyName] - : undefined; -} - -function messageForError(e: unknown): string { - const code = propertyFromUnknown(e, "code"); - const message = propertyFromUnknown(e, "message"); - if (code === "ENOENT") { - return "file not found"; - } else if (code === "EACCES") { - return "insufficient permissions to read file"; - } else if (typeof message === "string" && message.trim().length > 0) { - return message.trim(); - } else { - return `unknown error (${Bun.inspect(e)})`; - } -} - -interface ParseDateError { - type: "ParseDateError"; - message: string; -} - -function isParseDateError(e: unknown): e is ParseDateError { - return ( - e !== null && - typeof e === "object" && - "type" in e && - e.type === "ParseDateError" && - "message" in e && - typeof e.message === "string" - ); -} - -function parseDateToYYYYMMDD( - format: string, - text: string, -): string | ParseDateError { - let parsedDate: Date; - try { - parsedDate = tempoParse(text, format, "en"); - } catch (e: unknown) { - return { type: "ParseDateError", message: messageForError(e) }; - } - - if (isNaN(parsedDate.getTime())) { - return { type: "ParseDateError", message: "invalid date" }; - } - - return tempoFormat(parsedDate, "YYYY-MM-DD"); -} - -interface ReadPdfError { - type: "ReadPdfError"; - message: string; -} - -function isReadPdfError(e: unknown): e is ReadPdfError { - return ( - e !== null && - typeof e === "object" && - "type" in e && - e.type === "ReadPdfError" && - "message" in e && - typeof e.message === "string" - ); -} - -interface ReadPdfResult { - text: string; - lines: string[]; - hash: string; -} - -async function readPdf( - filePath: string, -): Promise { - let fileContents: Buffer; - try { - fileContents = await fs.readFile(filePath); - } catch (e: unknown) { - return { type: "ReadPdfError", message: messageForError(e) }; - } - - const parser = new PDFParse({ data: fileContents }); - - let text: string; - try { - const textContents = await parser.getText(); - text = textContents.text; - } catch (e: unknown) { - const errorMessage = messageForError(e); - const message = `parsing pdf file contents failed (${errorMessage})`; - return { type: "ReadPdfError", message }; - } finally { - await parser.destroy(); - } - - const lines = text.split("\n").map((line) => line.trim()); - const hash = Bun.CryptoHasher.hash("sha512-256", fileContents, "hex"); - return { text, lines, hash }; -} - -type PdfType = - | "PublicMobileStatement" - | "QuestradeRESPStatement" - | "QuestradeRRSPStatement" - | "QuestradeMarginStatement"; - -interface ParsePdfError { - type: "ParsePdfError"; - message: string; -} - -function isParsePdfError(e: unknown): e is ParsePdfError { - return ( - e !== null && - typeof e === "object" && - "type" in e && - e.type === "ParsePdfError" && - "message" in e && - typeof e.message === "string" - ); -} - -interface ParsedPublicMobileStatement { - type: "PublicMobileStatement"; - invoiceDate: string; - totalAmountPaid: string; -} - -function parsePublicMobileStatement( - pdfLines: string[], -): ParsedPublicMobileStatement | ParsePdfError { - const invoiceIndex = pdfLines.findIndex( - (line) => line.toLowerCase() === "invoice", - ); - if (invoiceIndex < 0) { - return { type: "ParsePdfError", message: "INVOICE line not found" }; - } - const invoiceDateStr = pdfLines[invoiceIndex + 1]?.trim(); - if (!invoiceDateStr) { - return { - type: "ParsePdfError", - message: "expected line after INVOICE line", - }; - } - const invoiceDate = parseDateToYYYYMMDD("MMM D, YYYY", invoiceDateStr); - if (isParseDateError(invoiceDate)) { - const { message } = invoiceDate; - return { - type: "ParsePdfError", - message: `unable to parse invoice date: ${invoiceDateStr} (${message})`, - }; - } - - const totalAmountPaidLine = pdfLines.find((line) => - line.toLowerCase().startsWith("total amount paid"), - ); - if (!totalAmountPaidLine) { - return { - type: "ParsePdfError", - message: "Total Amount Paid line not found", - }; - } - - const totalAmountPaid = totalAmountPaidLine.substring(17).trim(); - - return { type: "PublicMobileStatement", invoiceDate, totalAmountPaid }; -} - -interface ParsedQuestradeStatement { - type: QuestradeStatementType; - statementDate: string; - accountNumber: string; - balance: string; -} - -function parseQuestradeStatement( - pdfLines: readonly string[], -): ParsedQuestradeStatement | ParsePdfError { - const type = identifyQuestradeStatementType(pdfLines); - if (!type) { - return { - type: "ParsePdfError", - message: "Unable to determine Questrade statement type", - }; - } - - const accountNumberRegex = /Account\s*#:\s*(\d+)/i; - const accountNumberLine = pdfLines.find((line) => - accountNumberRegex.test(line), - ); - if (!accountNumberLine) { - return { type: "ParsePdfError", message: "Account number line not found" }; - } - const accountNumber = accountNumberLine.match(accountNumberRegex)?.[1]; - if (!accountNumber) { - throw new Error( - "internal error rhtan4myg2: accountNumberRegex should have matched", - ); - } - - const currentMonthRegex = /Current month\s*:\s*(\w+\s+\d+,\s*\d+)/i; - const currentMonthLine = pdfLines.find((line) => - line.match(currentMonthRegex), - ); - if (!currentMonthLine) { - return { type: "ParsePdfError", message: "Current month line not found" }; - } - const statementDateStr = currentMonthLine.match(currentMonthRegex)?.[1]; - if (!statementDateStr) { - throw new Error( - "internal error ydjbyakqr8: currentMonthRegex should have matched", - ); - } - const statementDate = parseDateToYYYYMMDD("MMMM D, YYYY", statementDateStr); - if (isParseDateError(statementDate)) { - const { message } = statementDate; - return { - type: "ParsePdfError", - message: - `unable to parse statement date: ${statementDateStr} ` + `(${message})`, - }; - } - - const balanceRegex = /Current month balance:\s*(\$[\d,.]+)/i; - const balanceLine = pdfLines.find((line) => line.match(balanceRegex)); - if (!balanceLine) { - return { type: "ParsePdfError", message: "Balance line not found" }; - } - const balance = balanceLine.match(balanceRegex)?.[1]; - if (!balance) { - throw new Error( - "internal error ky4fmdxh8b: balanceRegex should have matched", - ); - } - - return { type, statementDate, accountNumber, balance }; -} - -type ParsedPdf = ParsedPublicMobileStatement | ParsedQuestradeStatement; - -function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { - const type = identify(pdfLines); - if (!type) { - return { type: "ParsePdfError", message: "unrecognized pdf content" }; - } else if (type === "PublicMobileStatement") { - return parsePublicMobileStatement(pdfLines); - } else if (isQuestradeStatementType(type)) { - return parseQuestradeStatement(pdfLines); - } else { - unreachable(type, "unknown type"); - } -} - -function calculateFileName(parsedPdf: ParsedPdf): string { - if (parsedPdf.type === "PublicMobileStatement") { - const { invoiceDate, totalAmountPaid } = parsedPdf; - return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; - } else if (isQuestradeStatementType(parsedPdf.type)) { - const { statementDate, accountNumber, balance } = parsedPdf; - let typeName: string; - if (parsedPdf.type === "QuestradeRESPStatement") { - typeName = "RESP"; - } else if (parsedPdf.type === "QuestradeRRSPStatement") { - typeName = "RRSP"; - } else if (parsedPdf.type === "QuestradeMarginStatement") { - typeName = "Margin Account"; - } else { - unreachable(parsedPdf.type, "unknown type"); - } - return `${statementDate} Questrade ${typeName} ${accountNumber} Statement ${balance}.pdf`; - } else { - unreachable(parsedPdf.type, "unknown type"); - } -} - -interface CalculateFileNamesError { - type: "CalculateFileNamesError"; - message: string; - filePath: string; -} - -function isCalculateFileNamesError(e: unknown): e is CalculateFileNamesError { - return ( - e !== null && - typeof e === "object" && - "type" in e && - e.type === "CalculateFileNamesError" && - "message" in e && - typeof e.message === "string" && - "filePath" in e && - typeof e.filePath === "string" - ); -} - -async function calculateFileNames( - filePaths: string[], -): Promise | CalculateFileNamesError> { - const infoByFileName = new Map< - string, - Array<{ filePath: string; hash: string }> - >(); - const fileNameByFilePath = new Map(); - - for (const filePath of filePaths) { - if (fileNameByFilePath.has(filePath)) { - continue; - } - - const readPdfResult = await readPdf(filePath); - if (isReadPdfError(readPdfResult)) { - return { - type: "CalculateFileNamesError", - message: readPdfResult.message, - filePath, - }; - } - - const parsedPdf = parsePdf(readPdfResult.lines); - if (isParsePdfError(parsedPdf)) { - return { - type: "CalculateFileNamesError", - message: `unable to parse pdf contents: ${parsedPdf.message}`, - filePath, - }; - } - - const fileName = calculateFileName(parsedPdf); - fileNameByFilePath.set(filePath, fileName); - - const newFileNameInfo = { filePath, hash: readPdfResult.hash }; - const info = infoByFileName.get(fileName); - if (info) { - info.push(newFileNameInfo); - } else { - infoByFileName.set(fileName, [newFileNameInfo]); - } - } - - for (const [fileName, infoList] of infoByFileName.entries()) { - const hashes = new Set(); - for (const info of infoList) { - hashes.add(info.hash); - } - - if (hashes.size < 2) { - continue; - } - - const numberByHash = new Map(); - for (const hash of hashes) { - numberByHash.set(hash, numberByHash.size + 1); - } - - const fileNameByHash = new Map(); - for (const [hash, fileNumber] of numberByHash) { - const lastPeriodIndex = fileName.lastIndexOf("."); - let numberedFileName: string; - if (lastPeriodIndex < 0) { - numberedFileName = `${fileName} ${fileNumber}`; - } else { - numberedFileName = - fileName.substring(0, lastPeriodIndex) + - ` ${fileNumber}` + - fileName.substring(lastPeriodIndex); - } - fileNameByHash.set(hash, numberedFileName); - } - - for (const info of infoList) { - const newFileName = fileNameByHash.get(info.hash); - if (!newFileName) { - throw new Error( - `internal error kwteqzzx79: ` + - `fileNameByHash.get(info.hash) ` + - `returned ${Bun.inspect(newFileName)}`, - ); - } - - fileNameByFilePath.set(info.filePath, newFileName); - } - } - - return fileNameByFilePath; -} - -interface PrintOptions { - v?: boolean; -} - -async function printCommand( - filePath: string | string[], - options?: PrintOptions, -): Promise { - if (Array.isArray(filePath)) { - for (const currentFilePath of filePath) { - await printCommand(currentFilePath, options); - } - return; - } - - const readPdfResult = await readPdf(filePath); - if (isReadPdfError(readPdfResult)) { - console.error(`ERROR: ${readPdfResult.message}: ${filePath}`); - process.exit(1); - } - - if (options?.v) { - console.log(filePath); - } - console.log(readPdfResult.text); -} - -interface ParseOptions { - v?: boolean; -} - -async function parseCommand( - filePath: string | string[], - options?: ParseOptions, -): Promise { - if (Array.isArray(filePath)) { - for (const currentFilePath of filePath) { - await parseCommand(currentFilePath, options); - } - return; - } - - const text = await readPdf(filePath); - if (isReadPdfError(text)) { - console.error(`ERROR: ${text.message}: ${filePath}`); - process.exit(1); - } - - const parsedPdf = parsePdf(text.lines); - if (isParsePdfError(parsedPdf)) { - console.error( - `ERROR: unable to parse pdf contents: ` + - `${parsedPdf.message}: ${filePath}`, - ); - process.exit(1); - } - - if (options?.v) { - console.log(filePath); - } - console.log(parsedPdf); -} - -function identify(pdfLines: readonly string[]): PdfType | undefined { - if (pdfLines.includes("Public Mobile Account")) { - return "PublicMobileStatement"; - } - - if ( - pdfLines.some((line) => - line.toLowerCase().startsWith("questrade wealth management inc."), - ) - ) { - const questradeStatementType = identifyQuestradeStatementType(pdfLines); - if (questradeStatementType) { - return questradeStatementType; - } - } - - return undefined; -} - -type QuestradeStatementType = - | "QuestradeRESPStatement" - | "QuestradeRRSPStatement" - | "QuestradeMarginStatement"; - -function isQuestradeStatementType( - value: unknown, -): value is QuestradeStatementType { - return ( - value === "QuestradeRESPStatement" || - value === "QuestradeRRSPStatement" || - value === "QuestradeMarginStatement" - ); -} - -function identifyQuestradeStatementType( - pdfLines: readonly string[], -): QuestradeStatementType | undefined { - if (pdfLines.some((line) => line.includes("(RESP)"))) { - return "QuestradeRESPStatement"; - } - - if ( - pdfLines.some((line) => - line.toLowerCase().includes("registered retirement savings plan"), - ) - ) { - return "QuestradeRRSPStatement"; - } - - if ( - pdfLines.some((line) => - line.toLowerCase().includes("individual margin account"), - ) - ) { - return "QuestradeMarginStatement"; - } - - return undefined; -} - -interface IdentifyOptions { - v?: boolean; -} - -async function identifyCommand( - filePath: string | string[], - options?: IdentifyOptions, -): Promise { - if (Array.isArray(filePath)) { - for (const currentFilePath of filePath) { - await identifyCommand(currentFilePath, options); - } - return; - } - - const readPdfResult = await readPdf(filePath); - if (isReadPdfError(readPdfResult)) { - console.error(`ERROR: ${readPdfResult.message}: ${filePath}`); - process.exit(1); - } - - const type = identify(readPdfResult.lines); - if (options?.v) { - console.log(`${filePath}: ${type}`); - } else { - console.log(type); - } -} - -interface FilenameOptions { - v?: boolean; -} - -async function filenameCommand( - filePaths: string | string[], - options?: FilenameOptions, -): Promise { - if (typeof filePaths === "string") { - return filenameCommand([filePaths]); - } - - const outputFileNameByFilePath = await calculateFileNames(filePaths); - if (isCalculateFileNamesError(outputFileNameByFilePath)) { - const { message, filePath } = outputFileNameByFilePath; - console.error(`ERROR: ${message}: ${filePath}`); - process.exit(1); - } - - for (const filePath of filePaths) { - const outputFileName = outputFileNameByFilePath.get(filePath); - if (!outputFileName) { - throw new Error( - `internal error patvap56xt: ` + - `outputFileNameByFilePath.get(${Bun.inspect(filePath)}) ` + - `returned ${Bun.inspect(outputFileName)}`, - ); - } - - if (options?.v) { - console.log(`${filePath}: ${outputFileName}`); - } else { - console.log(outputFileName); - } - } -} - -interface RenameOptions { - v?: boolean; -} - -async function renameCommand( - filePaths: string | string[], - options?: RenameOptions, -): Promise { - if (typeof filePaths === "string") { - return renameCommand([filePaths]); - } - - const outputFileNameByFilePath = await calculateFileNames(filePaths); - if (isCalculateFileNamesError(outputFileNameByFilePath)) { - const { message, filePath } = outputFileNameByFilePath; - console.error(`ERROR: ${message}: ${filePath}`); - process.exit(1); - } - - const renamedPaths = new Set(); - let skippedCount = 0; - - for (const srcFilePath of filePaths) { - const destFileName = outputFileNameByFilePath.get(srcFilePath); - if (!destFileName) { - throw new Error( - `internal error xwttprzh98: ` + - `outputFileNameByFilePath.get(${Bun.inspect(srcFilePath)}) ` + - `returned ${Bun.inspect(destFileName)}`, - ); - } - - if (renamedPaths.has(srcFilePath)) { - console.log( - `Skipping renaming ${srcFilePath} to ${destFileName} (already processed)`, - ); - skippedCount++; - continue; - } - - const srcFileName = path.basename(srcFilePath); - if (srcFileName === destFileName) { - console.log( - `Skipping renaming ${srcFilePath} (already has the correct file name)`, - ); - skippedCount++; - continue; - } - - const dir = path.dirname(srcFilePath); - const destFilePath = path.join(dir, destFileName); - - if (options?.v) { - console.log(`Renaming ${srcFilePath} to ${destFileName}`); - } else { - console.log(destFilePath); - } - - await fs.rename(srcFilePath, destFilePath); - renamedPaths.add(srcFilePath); - } - - console.log(`${renamedPaths.size} files renamed (${skippedCount} skipped)`); -} - -program - .name("stmt") - .description( - "Reads PDF file and extracts information " + - "relevant for financial record keeping", - ) - .version("1.0.0"); - -program - .command("print") - .description("Reads PDF files and prints their text to stdout") - .argument("", "paths of the PDF files") - .option("-v", "print the file path on its own line before its contents") - .action(printCommand); - -program - .command("identify") - .description("Reads PDF files and prints their types to stdout") - .argument("", "paths of the PDF files") - .option("-v", "prefix each line with the file path") - .action(identifyCommand); - -program - .command("parse") - .description( - "Reads PDF files, parses their content, and prints them to stdout", - ) - .argument("", "paths of the PDF files") - .option("-v", "print the file path on its own line before its contents") - .action(parseCommand); - -program - .command("filename") - .description( - "Reads PDF files and prints their normalized file names to stdout", - ) - .argument("", "paths of the PDF files") - .option("-v", "prefix each line with the file path") - .action(filenameCommand); - -program - .command("rename") - .description("Renames PDF files to their normalized file names") - .argument("", "paths of the PDF files") - .option("-v", "prefix each output path with the source path") - .action(renameCommand); - -program.parse(process.argv); diff --git a/finances/unreachable.ts b/finances/unreachable.ts new file mode 100644 index 0000000..3402c63 --- /dev/null +++ b/finances/unreachable.ts @@ -0,0 +1,3 @@ +export function unreachable(value: never, message: string): never { + throw new Error(`should never get here: ${message} (${Bun.inspect(value)})`); +}