From 7be0f4e0130d1940199290df2d9e193732d0c5e6 Mon Sep 17 00:00:00 2001 From: denver Date: Thu, 18 Jun 2026 22:18:29 -0400 Subject: [PATCH 01/14] stmt.ts: rogers support added --- finances/stmt.ts | 137 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 120 insertions(+), 17 deletions(-) diff --git a/finances/stmt.ts b/finances/stmt.ts index affba6a..ffc5156 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -119,7 +119,8 @@ type PdfType = | "PublicMobileStatement" | "QuestradeRESPStatement" | "QuestradeRRSPStatement" - | "QuestradeMarginStatement"; + | "QuestradeMarginStatement" + | "RogersBill"; interface ParsePdfError { type: "ParsePdfError"; @@ -143,6 +144,14 @@ interface ParsedPublicMobileStatement { totalAmountPaid: string; } +function identifyPublicMobileStatementType( + pdfLines: readonly string[], +): "PublicMobileStatement" | undefined { + if (pdfLines.includes("Public Mobile Account")) { + return "PublicMobileStatement"; + } +} + function parsePublicMobileStatement( pdfLines: string[], ): ParsedPublicMobileStatement | ParsePdfError { @@ -253,16 +262,78 @@ function parseQuestradeStatement( return { type, statementDate, accountNumber, balance }; } -type ParsedPdf = ParsedPublicMobileStatement | ParsedQuestradeStatement; +interface ParsedRogersBill { + type: "RogersBill"; + billDate: string; + amountDue: string; +} + +function identifyRogersBillType( + pdfLines: readonly string[], +): "RogersBill" | undefined { + if (pdfLines.some((line) => line.toUpperCase().includes("1-888-ROGERS-1"))) { + return "RogersBill"; + } +} + +function parseRogersBill(pdfLines: string[]): ParsedRogersBill | ParsePdfError { + const amountDueIndex = pdfLines.findIndex( + (line) => line.toLowerCase() === "what is the total due?", + ); + if (amountDueIndex < 0) { + return { type: "ParsePdfError", message: "amount due line not found" }; + } + const amountDue = pdfLines[amountDueIndex + 1]?.trim(); + if (!amountDue) { + return { + type: "ParsePdfError", + message: "expected line after amount due line", + }; + } + + const billDateIndex = pdfLines.findIndex( + (line) => line.toLowerCase() === "bill date", + ); + if (billDateIndex < 0) { + return { type: "ParsePdfError", message: "bill date line not found" }; + } + const billDateStr = pdfLines[billDateIndex + 1]?.trim(); + if (!billDateStr) { + return { + type: "ParsePdfError", + message: "expected line after bill date line", + }; + } + const billDate = parseDateToYYYYMMDD("MMM D, YYYY", billDateStr); + if (isParseDateError(billDate)) { + const { message } = billDate; + return { + type: "ParsePdfError", + message: `unable to parse invoice date: ${billDateStr} (${message})`, + }; + } + + return { type: "RogersBill", billDate, amountDue }; +} + +type ParsedPdf = + | ParsedPublicMobileStatement + | ParsedQuestradeStatement + | ParsedRogersBill; function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { const type = identify(pdfLines); - if (!type) { + if (isIdentifyError(type)) { + const { message } = type; + return { type: "ParsePdfError", message }; + } else if (!type) { return { type: "ParsePdfError", message: "unrecognized pdf content" }; } else if (type === "PublicMobileStatement") { return parsePublicMobileStatement(pdfLines); } else if (isQuestradeStatementType(type)) { return parseQuestradeStatement(pdfLines); + } else if (type === "RogersBill") { + return parseRogersBill(pdfLines); } else { unreachable(type, "unknown type"); } @@ -272,6 +343,9 @@ function calculateFileName(parsedPdf: ParsedPdf): string { if (parsedPdf.type === "PublicMobileStatement") { const { invoiceDate, totalAmountPaid } = parsedPdf; return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; + } else if (parsedPdf.type === "RogersBill") { + const { billDate, amountDue } = parsedPdf; + return `${billDate} Rogers Bill ${amountDue}.pdf`; } else if (isQuestradeStatementType(parsedPdf.type)) { const { statementDate, accountNumber, balance } = parsedPdf; let typeName: string; @@ -463,23 +537,44 @@ async function parseCommand( console.log(parsedPdf); } -function identify(pdfLines: readonly string[]): PdfType | undefined { - if (pdfLines.includes("Public Mobile Account")) { - return "PublicMobileStatement"; - } +interface IdentifyError { + type: "IdentifyError"; + message: string; +} - if ( - pdfLines.some((line) => - line.toLowerCase().startsWith("questrade wealth management inc."), - ) - ) { - const questradeStatementType = identifyQuestradeStatementType(pdfLines); - if (questradeStatementType) { - return questradeStatementType; - } +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" + ); +} + +function identify( + pdfLines: readonly string[], +): PdfType | IdentifyError | undefined { + const types = [ + identifyPublicMobileStatementType(pdfLines), + identifyQuestradeStatementType(pdfLines), + identifyRogersBillType(pdfLines), + ]; + + const definedTypes = types.filter((type) => typeof type !== "undefined"); + + if (definedTypes.length > 1) { + const definedTypesStr = definedTypes.sort().join(); + return { + type: "IdentifyError", + message: + `Unable to uniquely identify the PDF type; ` + + `the PDF matched ${definedTypes.length} types: ${definedTypesStr}`, + }; } - return undefined; + return definedTypes[0]; } type QuestradeStatementType = @@ -500,6 +595,14 @@ function isQuestradeStatementType( function identifyQuestradeStatementType( pdfLines: readonly string[], ): QuestradeStatementType | undefined { + if ( + !pdfLines.some((line) => + line.toLowerCase().startsWith("questrade wealth management inc."), + ) + ) { + return undefined; + } + if (pdfLines.some((line) => line.includes("(RESP)"))) { return "QuestradeRESPStatement"; } From 8c7ddc62b0abb2c2db812a9745c350e0111d2692 Mon Sep 17 00:00:00 2001 From: denver Date: Thu, 18 Jun 2026 22:35:48 -0400 Subject: [PATCH 02/14] rogers.ts created --- finances/date.ts | 36 ++++++++++ finances/error.ts | 19 +++++ finances/parse_pdf_error.ts | 15 ++++ finances/rogers.ts | 61 ++++++++++++++++ finances/stmt.ts | 139 +++--------------------------------- 5 files changed, 140 insertions(+), 130 deletions(-) create mode 100644 finances/date.ts create mode 100644 finances/error.ts create mode 100644 finances/parse_pdf_error.ts create mode 100644 finances/rogers.ts 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/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/parse_pdf_error.ts b/finances/parse_pdf_error.ts new file mode 100644 index 0000000..d26bdc2 --- /dev/null +++ b/finances/parse_pdf_error.ts @@ -0,0 +1,15 @@ +export interface ParsePdfError { + type: "ParsePdfError"; + message: string; +} + +export 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" + ); +} diff --git a/finances/rogers.ts b/finances/rogers.ts new file mode 100644 index 0000000..6a20850 --- /dev/null +++ b/finances/rogers.ts @@ -0,0 +1,61 @@ +import { type ParsePdfError } from "./parse_pdf_error.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; + +export interface ParsedPdf { + type: "RogersBill"; + billDate: string; + amountDue: string; +} + +export function identify( + pdfLines: readonly string[], +): "RogersBill" | undefined { + if (pdfLines.some((line) => line.toUpperCase().includes("1-888-ROGERS-1"))) { + return "RogersBill"; + } +} + +export function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { + const amountDueIndex = pdfLines.findIndex( + (line) => line.toLowerCase() === "what is the total due?", + ); + if (amountDueIndex < 0) { + return { type: "ParsePdfError", message: "amount due line not found" }; + } + const amountDue = pdfLines[amountDueIndex + 1]?.trim(); + if (!amountDue) { + return { + type: "ParsePdfError", + message: "expected line after amount due line", + }; + } + + const billDateIndex = pdfLines.findIndex( + (line) => line.toLowerCase() === "bill date", + ); + if (billDateIndex < 0) { + return { type: "ParsePdfError", message: "bill date line not found" }; + } + const billDateStr = pdfLines[billDateIndex + 1]?.trim(); + if (!billDateStr) { + return { + type: "ParsePdfError", + message: "expected line after bill date line", + }; + } + const billDate = parseDateToYYYYMMDD("MMM D, YYYY", billDateStr); + if (isParseDateError(billDate)) { + const { message } = billDate; + return { + type: "ParsePdfError", + message: `unable to parse invoice date: ${billDateStr} (${message})`, + }; + } + + return { type: "RogersBill", billDate, amountDue }; +} + +export function calculateFileName(parsedPdf: ParsedPdf): string { + const { billDate, amountDue } = parsedPdf; + return `${billDate} Rogers Bill ${amountDue}.pdf`; +} diff --git a/finances/stmt.ts b/finances/stmt.ts index ffc5156..b4e4db4 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -1,69 +1,19 @@ 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"; +import { type ParsePdfError, isParsePdfError } from "./parse_pdf_error.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; +import { messageForError } from "./error.ts"; +import * as rogers from "./rogers.ts"; + 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; @@ -122,22 +72,6 @@ type PdfType = | "QuestradeMarginStatement" | "RogersBill"; -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; @@ -262,64 +196,10 @@ function parseQuestradeStatement( return { type, statementDate, accountNumber, balance }; } -interface ParsedRogersBill { - type: "RogersBill"; - billDate: string; - amountDue: string; -} - -function identifyRogersBillType( - pdfLines: readonly string[], -): "RogersBill" | undefined { - if (pdfLines.some((line) => line.toUpperCase().includes("1-888-ROGERS-1"))) { - return "RogersBill"; - } -} - -function parseRogersBill(pdfLines: string[]): ParsedRogersBill | ParsePdfError { - const amountDueIndex = pdfLines.findIndex( - (line) => line.toLowerCase() === "what is the total due?", - ); - if (amountDueIndex < 0) { - return { type: "ParsePdfError", message: "amount due line not found" }; - } - const amountDue = pdfLines[amountDueIndex + 1]?.trim(); - if (!amountDue) { - return { - type: "ParsePdfError", - message: "expected line after amount due line", - }; - } - - const billDateIndex = pdfLines.findIndex( - (line) => line.toLowerCase() === "bill date", - ); - if (billDateIndex < 0) { - return { type: "ParsePdfError", message: "bill date line not found" }; - } - const billDateStr = pdfLines[billDateIndex + 1]?.trim(); - if (!billDateStr) { - return { - type: "ParsePdfError", - message: "expected line after bill date line", - }; - } - const billDate = parseDateToYYYYMMDD("MMM D, YYYY", billDateStr); - if (isParseDateError(billDate)) { - const { message } = billDate; - return { - type: "ParsePdfError", - message: `unable to parse invoice date: ${billDateStr} (${message})`, - }; - } - - return { type: "RogersBill", billDate, amountDue }; -} - type ParsedPdf = | ParsedPublicMobileStatement | ParsedQuestradeStatement - | ParsedRogersBill; + | rogers.ParsedPdf; function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { const type = identify(pdfLines); @@ -333,7 +213,7 @@ function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { } else if (isQuestradeStatementType(type)) { return parseQuestradeStatement(pdfLines); } else if (type === "RogersBill") { - return parseRogersBill(pdfLines); + return rogers.parsePdf(pdfLines); } else { unreachable(type, "unknown type"); } @@ -344,8 +224,7 @@ function calculateFileName(parsedPdf: ParsedPdf): string { const { invoiceDate, totalAmountPaid } = parsedPdf; return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; } else if (parsedPdf.type === "RogersBill") { - const { billDate, amountDue } = parsedPdf; - return `${billDate} Rogers Bill ${amountDue}.pdf`; + return rogers.calculateFileName(parsedPdf); } else if (isQuestradeStatementType(parsedPdf.type)) { const { statementDate, accountNumber, balance } = parsedPdf; let typeName: string; @@ -559,7 +438,7 @@ function identify( const types = [ identifyPublicMobileStatementType(pdfLines), identifyQuestradeStatementType(pdfLines), - identifyRogersBillType(pdfLines), + rogers.identify(pdfLines), ]; const definedTypes = types.filter((type) => typeof type !== "undefined"); From e769e6c44659c8fdb0489333919a3753f9f95efd Mon Sep 17 00:00:00 2001 From: denver Date: Thu, 18 Jun 2026 22:41:43 -0400 Subject: [PATCH 03/14] public_mobile.ts added --- finances/public_mobile.ts | 59 ++++++++++++++++++++++++++++++++++++ finances/stmt.ts | 64 +++------------------------------------ 2 files changed, 64 insertions(+), 59 deletions(-) create mode 100644 finances/public_mobile.ts diff --git a/finances/public_mobile.ts b/finances/public_mobile.ts new file mode 100644 index 0000000..0cb2017 --- /dev/null +++ b/finances/public_mobile.ts @@ -0,0 +1,59 @@ +import { type ParsePdfError } from "./parse_pdf_error.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; + +export interface ParsedPdf { + type: "PublicMobileStatement"; + invoiceDate: string; + totalAmountPaid: string; +} + +export function identify( + pdfLines: readonly string[], +): "PublicMobileStatement" | undefined { + if (pdfLines.includes("Public Mobile Account")) { + return "PublicMobileStatement"; + } +} + +export function parsePdf(pdfLines: string[]): ParsedPdf | 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 }; +} + +export function calculateFileName(parsedPdf: ParsedPdf): string { + const { invoiceDate, totalAmountPaid } = parsedPdf; + return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; +} diff --git a/finances/stmt.ts b/finances/stmt.ts index b4e4db4..c443ad3 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -7,6 +7,7 @@ import { type ParsePdfError, isParsePdfError } from "./parse_pdf_error.ts"; import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; import { messageForError } from "./error.ts"; import * as rogers from "./rogers.ts"; +import * as publicMobile from "./public_mobile.ts"; const program = new Command(); @@ -72,60 +73,6 @@ type PdfType = | "QuestradeMarginStatement" | "RogersBill"; -interface ParsedPublicMobileStatement { - type: "PublicMobileStatement"; - invoiceDate: string; - totalAmountPaid: string; -} - -function identifyPublicMobileStatementType( - pdfLines: readonly string[], -): "PublicMobileStatement" | undefined { - if (pdfLines.includes("Public Mobile Account")) { - return "PublicMobileStatement"; - } -} - -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; @@ -197,8 +144,8 @@ function parseQuestradeStatement( } type ParsedPdf = - | ParsedPublicMobileStatement | ParsedQuestradeStatement + | publicMobile.ParsedPdf | rogers.ParsedPdf; function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { @@ -209,7 +156,7 @@ function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { } else if (!type) { return { type: "ParsePdfError", message: "unrecognized pdf content" }; } else if (type === "PublicMobileStatement") { - return parsePublicMobileStatement(pdfLines); + return publicMobile.parsePdf(pdfLines); } else if (isQuestradeStatementType(type)) { return parseQuestradeStatement(pdfLines); } else if (type === "RogersBill") { @@ -221,8 +168,7 @@ function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { function calculateFileName(parsedPdf: ParsedPdf): string { if (parsedPdf.type === "PublicMobileStatement") { - const { invoiceDate, totalAmountPaid } = parsedPdf; - return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; + return publicMobile.calculateFileName(parsedPdf); } else if (parsedPdf.type === "RogersBill") { return rogers.calculateFileName(parsedPdf); } else if (isQuestradeStatementType(parsedPdf.type)) { @@ -436,7 +382,7 @@ function identify( pdfLines: readonly string[], ): PdfType | IdentifyError | undefined { const types = [ - identifyPublicMobileStatementType(pdfLines), + publicMobile.identify(pdfLines), identifyQuestradeStatementType(pdfLines), rogers.identify(pdfLines), ]; From 187f8c144ea0f0bc0be1d6b59a239543fbad2866 Mon Sep 17 00:00:00 2001 From: denver Date: Thu, 18 Jun 2026 22:56:23 -0400 Subject: [PATCH 04/14] questrade.ts created --- finances/questrade.ts | 136 ++++++++++++++++++++++++++++++++++++ finances/stmt.ts | 149 +++------------------------------------- finances/unreachable.ts | 3 + 3 files changed, 147 insertions(+), 141 deletions(-) create mode 100644 finances/questrade.ts create mode 100644 finances/unreachable.ts diff --git a/finances/questrade.ts b/finances/questrade.ts new file mode 100644 index 0000000..1afd4c7 --- /dev/null +++ b/finances/questrade.ts @@ -0,0 +1,136 @@ +import { type ParsePdfError } from "./parse_pdf_error.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; +import { unreachable } from "./unreachable.ts"; + +export type StatementType = + | "QuestradeRESPStatement" + | "QuestradeRRSPStatement" + | "QuestradeMarginStatement"; + +export function isStatementType(value: unknown): value is StatementType { + return ( + value === "QuestradeRESPStatement" || + value === "QuestradeRRSPStatement" || + value === "QuestradeMarginStatement" + ); +} + +export interface ParsedPdf { + type: StatementType; + statementDate: string; + accountNumber: string; + balance: string; +} + +export function identify( + pdfLines: readonly string[], +): StatementType | undefined { + if ( + !pdfLines.some((line) => + line.toLowerCase().startsWith("questrade wealth management inc."), + ) + ) { + return 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; +} + +export function parsePdf( + pdfLines: readonly string[], +): ParsedPdf | ParsePdfError { + const type = identify(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 }; +} + +export function calculateFileName(parsedPdf: ParsedPdf): string { + const { type, statementDate, accountNumber, balance } = parsedPdf; + let typeName: string; + if (type === "QuestradeRESPStatement") { + typeName = "RESP"; + } else if (type === "QuestradeRRSPStatement") { + typeName = "RRSP"; + } else if (type === "QuestradeMarginStatement") { + typeName = "Margin Account"; + } else { + unreachable(type, "unknown type"); + } + + return `${statementDate} Questrade ${typeName} ${accountNumber} Statement ${balance}.pdf`; +} diff --git a/finances/stmt.ts b/finances/stmt.ts index c443ad3..aa993a7 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -4,17 +4,14 @@ import { PDFParse } from "pdf-parse"; import * as path from "node:path"; import { type ParsePdfError, isParsePdfError } from "./parse_pdf_error.ts"; -import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; import { messageForError } from "./error.ts"; +import { unreachable } from "./unreachable.ts"; import * as rogers from "./rogers.ts"; import * as publicMobile from "./public_mobile.ts"; +import * as questrade from "./questrade.ts"; const program = new Command(); -function unreachable(value: never, message: string): never { - throw new Error(`should never get here: ${message} (${Bun.inspect(value)})`); -} - interface ReadPdfError { type: "ReadPdfError"; message: string; @@ -73,78 +70,8 @@ type PdfType = | "QuestradeMarginStatement" | "RogersBill"; -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 = - | ParsedQuestradeStatement + | questrade.ParsedPdf | publicMobile.ParsedPdf | rogers.ParsedPdf; @@ -157,8 +84,8 @@ function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { return { type: "ParsePdfError", message: "unrecognized pdf content" }; } else if (type === "PublicMobileStatement") { return publicMobile.parsePdf(pdfLines); - } else if (isQuestradeStatementType(type)) { - return parseQuestradeStatement(pdfLines); + } else if (questrade.isStatementType(type)) { + return questrade.parsePdf(pdfLines); } else if (type === "RogersBill") { return rogers.parsePdf(pdfLines); } else { @@ -171,19 +98,8 @@ function calculateFileName(parsedPdf: ParsedPdf): string { return publicMobile.calculateFileName(parsedPdf); } else if (parsedPdf.type === "RogersBill") { return rogers.calculateFileName(parsedPdf); - } 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 if (questrade.isStatementType(parsedPdf.type)) { + return questrade.calculateFileName(parsedPdf); } else { unreachable(parsedPdf.type, "unknown type"); } @@ -382,8 +298,8 @@ function identify( pdfLines: readonly string[], ): PdfType | IdentifyError | undefined { const types = [ + questrade.identify(pdfLines), publicMobile.identify(pdfLines), - identifyQuestradeStatementType(pdfLines), rogers.identify(pdfLines), ]; @@ -402,55 +318,6 @@ function identify( return definedTypes[0]; } -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.toLowerCase().startsWith("questrade wealth management inc."), - ) - ) { - return 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; } 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)})`); +} From d2f425e19e224935ca472120858550624a8a12a5 Mon Sep 17 00:00:00 2001 From: denver Date: Thu, 18 Jun 2026 23:25:19 -0400 Subject: [PATCH 05/14] document.ts added and rogers and public mobile converted to it --- finances/document.ts | 18 ++++++++ finances/public_mobile.ts | 94 ++++++++++++++++++++------------------ finances/rogers.ts | 96 ++++++++++++++++++++------------------- finances/stmt.ts | 33 ++++++++------ 4 files changed, 139 insertions(+), 102 deletions(-) create mode 100644 finances/document.ts diff --git a/finances/document.ts b/finances/document.ts new file mode 100644 index 0000000..c950ae3 --- /dev/null +++ b/finances/document.ts @@ -0,0 +1,18 @@ +import { type ParsePdfError } from "./parse_pdf_error.ts"; + +export interface ParsedDocument { + readonly type: TypeName; +} + +export interface Document< + ParsedPdf extends ParsedDocument, + TypeName extends string, +> { + readonly type: TypeName; + + identify(lines: readonly string[]): boolean; + + parse(lines: readonly string[]): ParsedPdf | ParsePdfError; + + calculateFileName(pdf: Readonly): string; +} diff --git a/finances/public_mobile.ts b/finances/public_mobile.ts index 0cb2017..10a018e 100644 --- a/finances/public_mobile.ts +++ b/finances/public_mobile.ts @@ -1,59 +1,67 @@ +import { type Document } from "./document.ts"; import { type ParsePdfError } from "./parse_pdf_error.ts"; import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; -export interface ParsedPdf { +export interface ParsedPublicMobileStatement { type: "PublicMobileStatement"; invoiceDate: string; totalAmountPaid: string; } -export function identify( - pdfLines: readonly string[], -): "PublicMobileStatement" | undefined { - if (pdfLines.includes("Public Mobile Account")) { - return "PublicMobileStatement"; - } -} +class PublicMobileStatement implements Document< + ParsedPublicMobileStatement, + "PublicMobileStatement" +> { + readonly type = "PublicMobileStatement" as const; -export function parsePdf(pdfLines: string[]): ParsedPdf | 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})`, - }; + identify(lines: readonly string[]): boolean { + return lines.includes("Public Mobile Account"); } - const totalAmountPaidLine = pdfLines.find((line) => - line.toLowerCase().startsWith("total amount paid"), - ); - if (!totalAmountPaidLine) { - return { - type: "ParsePdfError", - message: "Total Amount Paid line not found", - }; + calculateFileName(pdf: Readonly): string { + const { invoiceDate, totalAmountPaid } = pdf; + return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; } - const totalAmountPaid = totalAmountPaidLine.substring(17).trim(); + parse(lines: readonly string[]): ParsedPublicMobileStatement | ParsePdfError { + const invoiceIndex = lines.findIndex( + (line) => line.toLowerCase() === "invoice", + ); + if (invoiceIndex < 0) { + return { type: "ParsePdfError", message: "INVOICE line not found" }; + } + const invoiceDateStr = lines[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})`, + }; + } - return { type: "PublicMobileStatement", invoiceDate, totalAmountPaid }; -} + const totalAmountPaidLine = lines.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(); -export function calculateFileName(parsedPdf: ParsedPdf): string { - const { invoiceDate, totalAmountPaid } = parsedPdf; - return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; + return { type: "PublicMobileStatement", invoiceDate, totalAmountPaid }; + } } + +export const publicMobileStatement: Readonly< + Document +> = Object.freeze(new PublicMobileStatement()); diff --git a/finances/rogers.ts b/finances/rogers.ts index 6a20850..e02fff8 100644 --- a/finances/rogers.ts +++ b/finances/rogers.ts @@ -1,61 +1,65 @@ +import { type Document } from "./document.ts"; import { type ParsePdfError } from "./parse_pdf_error.ts"; import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; -export interface ParsedPdf { +export interface ParsedRogersBill { type: "RogersBill"; billDate: string; amountDue: string; } -export function identify( - pdfLines: readonly string[], -): "RogersBill" | undefined { - if (pdfLines.some((line) => line.toUpperCase().includes("1-888-ROGERS-1"))) { - return "RogersBill"; - } -} +class RogersBill implements Document { + readonly type = "RogersBill" as const; -export function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { - const amountDueIndex = pdfLines.findIndex( - (line) => line.toLowerCase() === "what is the total due?", - ); - if (amountDueIndex < 0) { - return { type: "ParsePdfError", message: "amount due line not found" }; - } - const amountDue = pdfLines[amountDueIndex + 1]?.trim(); - if (!amountDue) { - return { - type: "ParsePdfError", - message: "expected line after amount due line", - }; + identify(lines: readonly string[]): boolean { + return lines.some((line) => line.toUpperCase().includes("1-888-ROGERS-1")); } - const billDateIndex = pdfLines.findIndex( - (line) => line.toLowerCase() === "bill date", - ); - if (billDateIndex < 0) { - return { type: "ParsePdfError", message: "bill date line not found" }; - } - const billDateStr = pdfLines[billDateIndex + 1]?.trim(); - if (!billDateStr) { - return { - type: "ParsePdfError", - message: "expected line after bill date line", - }; - } - const billDate = parseDateToYYYYMMDD("MMM D, YYYY", billDateStr); - if (isParseDateError(billDate)) { - const { message } = billDate; - return { - type: "ParsePdfError", - message: `unable to parse invoice date: ${billDateStr} (${message})`, - }; + calculateFileName(pdf: Readonly): string { + const { billDate, amountDue } = pdf; + return `${billDate} Rogers Bill ${amountDue}.pdf`; } - return { type: "RogersBill", billDate, amountDue }; -} + parse(lines: readonly string[]): ParsedRogersBill | ParsePdfError { + const amountDueIndex = lines.findIndex( + (line) => line.toLowerCase() === "what is the total due?", + ); + if (amountDueIndex < 0) { + return { type: "ParsePdfError", message: "amount due line not found" }; + } + const amountDue = lines[amountDueIndex + 1]?.trim(); + if (!amountDue) { + return { + type: "ParsePdfError", + message: "expected line after amount due line", + }; + } + + const billDateIndex = lines.findIndex( + (line) => line.toLowerCase() === "bill date", + ); + if (billDateIndex < 0) { + return { type: "ParsePdfError", message: "bill date line not found" }; + } + const billDateStr = lines[billDateIndex + 1]?.trim(); + if (!billDateStr) { + return { + type: "ParsePdfError", + message: "expected line after bill date line", + }; + } + const billDate = parseDateToYYYYMMDD("MMM D, YYYY", billDateStr); + if (isParseDateError(billDate)) { + const { message } = billDate; + return { + type: "ParsePdfError", + message: `unable to parse invoice date: ${billDateStr} (${message})`, + }; + } -export function calculateFileName(parsedPdf: ParsedPdf): string { - const { billDate, amountDue } = parsedPdf; - return `${billDate} Rogers Bill ${amountDue}.pdf`; + return { type: "RogersBill", billDate, amountDue }; + } } + +export const rogersBill: Readonly> = + Object.freeze(new RogersBill()); diff --git a/finances/stmt.ts b/finances/stmt.ts index aa993a7..d6c9f65 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -6,8 +6,11 @@ import * as path from "node:path"; import { type ParsePdfError, isParsePdfError } from "./parse_pdf_error.ts"; import { messageForError } from "./error.ts"; import { unreachable } from "./unreachable.ts"; -import * as rogers from "./rogers.ts"; -import * as publicMobile from "./public_mobile.ts"; +import { rogersBill, type ParsedRogersBill } from "./rogers.ts"; +import { + publicMobileStatement, + type ParsedPublicMobileStatement, +} from "./public_mobile.ts"; import * as questrade from "./questrade.ts"; const program = new Command(); @@ -72,8 +75,8 @@ type PdfType = type ParsedPdf = | questrade.ParsedPdf - | publicMobile.ParsedPdf - | rogers.ParsedPdf; + | ParsedPublicMobileStatement + | ParsedRogersBill; function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { const type = identify(pdfLines); @@ -83,11 +86,11 @@ function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { } else if (!type) { return { type: "ParsePdfError", message: "unrecognized pdf content" }; } else if (type === "PublicMobileStatement") { - return publicMobile.parsePdf(pdfLines); + return publicMobileStatement.parse(pdfLines); } else if (questrade.isStatementType(type)) { return questrade.parsePdf(pdfLines); } else if (type === "RogersBill") { - return rogers.parsePdf(pdfLines); + return rogersBill.parse(pdfLines); } else { unreachable(type, "unknown type"); } @@ -95,9 +98,9 @@ function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { function calculateFileName(parsedPdf: ParsedPdf): string { if (parsedPdf.type === "PublicMobileStatement") { - return publicMobile.calculateFileName(parsedPdf); + return publicMobileStatement.calculateFileName(parsedPdf); } else if (parsedPdf.type === "RogersBill") { - return rogers.calculateFileName(parsedPdf); + return rogersBill.calculateFileName(parsedPdf); } else if (questrade.isStatementType(parsedPdf.type)) { return questrade.calculateFileName(parsedPdf); } else { @@ -297,11 +300,15 @@ function isIdentifyError(e: unknown): e is IdentifyError { function identify( pdfLines: readonly string[], ): PdfType | IdentifyError | undefined { - const types = [ - questrade.identify(pdfLines), - publicMobile.identify(pdfLines), - rogers.identify(pdfLines), - ]; + const types: Array = [questrade.identify(pdfLines)]; + + if (publicMobileStatement.identify(pdfLines)) { + types.push(publicMobileStatement.type); + } + + if (rogersBill.identify(pdfLines)) { + types.push(rogersBill.type); + } const definedTypes = types.filter((type) => typeof type !== "undefined"); From 9c20937ebd1e722c648181b82060377a44a46b46 Mon Sep 17 00:00:00 2001 From: denver Date: Thu, 18 Jun 2026 23:51:42 -0400 Subject: [PATCH 06/14] questrade.ts refactored --- finances/questrade.ts | 218 ++++++++++++++++++++++++------------------ finances/stmt.ts | 43 +++++++-- 2 files changed, 160 insertions(+), 101 deletions(-) diff --git a/finances/questrade.ts b/finances/questrade.ts index 1afd4c7..7728821 100644 --- a/finances/questrade.ts +++ b/finances/questrade.ts @@ -1,6 +1,6 @@ +import { type Document } from "./document.ts"; import { type ParsePdfError } from "./parse_pdf_error.ts"; import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; -import { unreachable } from "./unreachable.ts"; export type StatementType = | "QuestradeRESPStatement" @@ -15,122 +15,152 @@ export function isStatementType(value: unknown): value is StatementType { ); } -export interface ParsedPdf { - type: StatementType; +export interface ParsedQuestradeStatement { + type: Type; statementDate: string; accountNumber: string; balance: string; } -export function identify( - pdfLines: readonly string[], -): StatementType | undefined { - if ( - !pdfLines.some((line) => - line.toLowerCase().startsWith("questrade wealth management inc."), - ) - ) { - return undefined; - } +export type ParsedQuestradeRESPStatement = + ParsedQuestradeStatement<"QuestradeRESPStatement">; +export type ParsedQuestradeRRSPStatement = + ParsedQuestradeStatement<"QuestradeRRSPStatement">; +export type ParsedQuestradeMarginStatement = + ParsedQuestradeStatement<"QuestradeMarginStatement">; - if (pdfLines.some((line) => line.includes("(RESP)"))) { - return "QuestradeRESPStatement"; - } +class QuestradeStatement implements Document< + ParsedQuestradeStatement, + Type +> { + readonly type: Type; + readonly #identifyingLine: string; + readonly #fileNameType: string; - if ( - pdfLines.some((line) => - line.toLowerCase().includes("registered retirement savings plan"), - ) - ) { - return "QuestradeRRSPStatement"; + constructor(type: Type, identifyingLine: string, fileNameType: string) { + this.type = type; + this.#identifyingLine = identifyingLine.toLowerCase(); + this.#fileNameType = fileNameType; } - if ( - pdfLines.some((line) => - line.toLowerCase().includes("individual margin account"), - ) - ) { - return "QuestradeMarginStatement"; - } + identify(lines: readonly string[]): boolean { + if ( + !lines.some((line) => + line.toLowerCase().startsWith("questrade wealth management inc."), + ) + ) { + return false; + } - return undefined; -} + if ( + lines.some((line) => line.toLowerCase().includes(this.#identifyingLine)) + ) { + return true; + } -export function parsePdf( - pdfLines: readonly string[], -): ParsedPdf | ParsePdfError { - const type = identify(pdfLines); - if (!type) { - return { - type: "ParsePdfError", - message: "Unable to determine Questrade statement type", - }; + return false; } - 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", + calculateFileName(pdf: Readonly>): string { + const { statementDate, accountNumber, balance } = pdf; + return ( + `${statementDate} Questrade ${this.#fileNameType} ` + + `${accountNumber} Statement ${balance}.pdf` ); } - 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", + parse( + lines: readonly string[], + ): ParsedQuestradeStatement | ParsePdfError { + const accountNumberRegex = /Account\s*#:\s*(\d+)/i; + const accountNumberLine = lines.find((line) => + accountNumberRegex.test(line), ); - } - const statementDate = parseDateToYYYYMMDD("MMMM D, YYYY", statementDateStr); - if (isParseDateError(statementDate)) { - const { message } = statementDate; - return { - type: "ParsePdfError", - message: - `unable to parse statement date: ${statementDateStr} ` + `(${message})`, - }; - } + 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 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", + const currentMonthRegex = /Current month\s*:\s*(\w+\s+\d+,\s*\d+)/i; + const currentMonthLine = lines.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 = lines.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: this.type, statementDate, accountNumber, balance }; } +} - return { type, statementDate, accountNumber, balance }; +class QuestradeRESPStatement extends QuestradeStatement<"QuestradeRESPStatement"> { + constructor() { + super("QuestradeRESPStatement", "(RESP)", "RESP"); + } } -export function calculateFileName(parsedPdf: ParsedPdf): string { - const { type, statementDate, accountNumber, balance } = parsedPdf; - let typeName: string; - if (type === "QuestradeRESPStatement") { - typeName = "RESP"; - } else if (type === "QuestradeRRSPStatement") { - typeName = "RRSP"; - } else if (type === "QuestradeMarginStatement") { - typeName = "Margin Account"; - } else { - unreachable(type, "unknown type"); +class QuestradeRRSPStatement extends QuestradeStatement<"QuestradeRRSPStatement"> { + constructor() { + super( + "QuestradeRRSPStatement", + "registered retirement savings plan", + "RRSP", + ); } +} - return `${statementDate} Questrade ${typeName} ${accountNumber} Statement ${balance}.pdf`; +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/stmt.ts b/finances/stmt.ts index d6c9f65..1927781 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -11,7 +11,14 @@ import { publicMobileStatement, type ParsedPublicMobileStatement, } from "./public_mobile.ts"; -import * as questrade from "./questrade.ts"; +import { + type ParsedQuestradeMarginStatement, + type ParsedQuestradeRESPStatement, + type ParsedQuestradeRRSPStatement, + questradeMarginStatement, + questradeRESPStatement, + questradeRRSPStatement, +} from "./questrade.ts"; const program = new Command(); @@ -74,7 +81,9 @@ type PdfType = | "RogersBill"; type ParsedPdf = - | questrade.ParsedPdf + | ParsedQuestradeMarginStatement + | ParsedQuestradeRESPStatement + | ParsedQuestradeRRSPStatement | ParsedPublicMobileStatement | ParsedRogersBill; @@ -87,8 +96,12 @@ function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { return { type: "ParsePdfError", message: "unrecognized pdf content" }; } else if (type === "PublicMobileStatement") { return publicMobileStatement.parse(pdfLines); - } else if (questrade.isStatementType(type)) { - return questrade.parsePdf(pdfLines); + } else if (type === "QuestradeRESPStatement") { + return questradeRESPStatement.parse(pdfLines); + } else if (type === "QuestradeRRSPStatement") { + return questradeRRSPStatement.parse(pdfLines); + } else if (type === "QuestradeMarginStatement") { + return questradeMarginStatement.parse(pdfLines); } else if (type === "RogersBill") { return rogersBill.parse(pdfLines); } else { @@ -101,8 +114,12 @@ function calculateFileName(parsedPdf: ParsedPdf): string { return publicMobileStatement.calculateFileName(parsedPdf); } else if (parsedPdf.type === "RogersBill") { return rogersBill.calculateFileName(parsedPdf); - } else if (questrade.isStatementType(parsedPdf.type)) { - return questrade.calculateFileName(parsedPdf); + } else if (parsedPdf.type === "QuestradeRESPStatement") { + return questradeRESPStatement.calculateFileName(parsedPdf); + } else if (parsedPdf.type === "QuestradeRRSPStatement") { + return questradeRRSPStatement.calculateFileName(parsedPdf); + } else if (parsedPdf.type === "QuestradeMarginStatement") { + return questradeMarginStatement.calculateFileName(parsedPdf); } else { unreachable(parsedPdf.type, "unknown type"); } @@ -300,7 +317,19 @@ function isIdentifyError(e: unknown): e is IdentifyError { function identify( pdfLines: readonly string[], ): PdfType | IdentifyError | undefined { - const types: Array = [questrade.identify(pdfLines)]; + const types: Array = []; + + if (questradeRESPStatement.identify(pdfLines)) { + types.push(questradeRESPStatement.type); + } + + if (questradeRRSPStatement.identify(pdfLines)) { + types.push(questradeRRSPStatement.type); + } + + if (questradeMarginStatement.identify(pdfLines)) { + types.push(questradeMarginStatement.type); + } if (publicMobileStatement.identify(pdfLines)) { types.push(publicMobileStatement.type); From b8fde71b79e4b1ea35681276aa7f7e2b8edf70f6 Mon Sep 17 00:00:00 2001 From: denver Date: Fri, 19 Jun 2026 00:16:12 -0400 Subject: [PATCH 07/14] identify.ts added --- finances/documents.ts | 17 +++++++++++ finances/identify.ts | 38 ++++++++++++++++++++++++ finances/stmt.ts | 67 +++++++------------------------------------ 3 files changed, 65 insertions(+), 57 deletions(-) create mode 100644 finances/documents.ts create mode 100644 finances/identify.ts diff --git a/finances/documents.ts b/finances/documents.ts new file mode 100644 index 0000000..1ae4d7f --- /dev/null +++ b/finances/documents.ts @@ -0,0 +1,17 @@ +import { rogersBill } from "./rogers.ts"; +import { publicMobileStatement } from "./public_mobile.ts"; +import { + questradeMarginStatement, + questradeRESPStatement, + questradeRRSPStatement, +} from "./questrade.ts"; + +export const allDocuments = Object.freeze([ + publicMobileStatement, + rogersBill, + questradeMarginStatement, + questradeRESPStatement, + questradeRRSPStatement, +]); + +export type Documents = Exclude<(typeof allDocuments)[number], 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/stmt.ts b/finances/stmt.ts index 1927781..e5891a4 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -4,6 +4,7 @@ import { PDFParse } from "pdf-parse"; import * as path from "node:path"; import { type ParsePdfError, isParsePdfError } from "./parse_pdf_error.ts"; +import { identify, isIdentifyError } from "./identify.ts"; import { messageForError } from "./error.ts"; import { unreachable } from "./unreachable.ts"; import { rogersBill, type ParsedRogersBill } from "./rogers.ts"; @@ -298,62 +299,6 @@ async function parseCommand( console.log(parsedPdf); } -interface IdentifyError { - type: "IdentifyError"; - message: string; -} - -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" - ); -} - -function identify( - pdfLines: readonly string[], -): PdfType | IdentifyError | undefined { - const types: Array = []; - - if (questradeRESPStatement.identify(pdfLines)) { - types.push(questradeRESPStatement.type); - } - - if (questradeRRSPStatement.identify(pdfLines)) { - types.push(questradeRRSPStatement.type); - } - - if (questradeMarginStatement.identify(pdfLines)) { - types.push(questradeMarginStatement.type); - } - - if (publicMobileStatement.identify(pdfLines)) { - types.push(publicMobileStatement.type); - } - - if (rogersBill.identify(pdfLines)) { - types.push(rogersBill.type); - } - - const definedTypes = types.filter((type) => typeof type !== "undefined"); - - if (definedTypes.length > 1) { - const definedTypesStr = definedTypes.sort().join(); - return { - type: "IdentifyError", - message: - `Unable to uniquely identify the PDF type; ` + - `the PDF matched ${definedTypes.length} types: ${definedTypesStr}`, - }; - } - - return definedTypes[0]; -} - interface IdentifyOptions { v?: boolean; } @@ -375,7 +320,15 @@ async function identifyCommand( process.exit(1); } - const type = identify(readPdfResult.lines); + 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 { From 7ac64fa01a5099c6b07913a7fb3cc388a0dcedf3 Mon Sep 17 00:00:00 2001 From: denver Date: Fri, 19 Jun 2026 00:23:45 -0400 Subject: [PATCH 08/14] stmt.ts: fix parse command --- finances/stmt.ts | 77 ++++++++++++++---------------------------------- 1 file changed, 22 insertions(+), 55 deletions(-) diff --git a/finances/stmt.ts b/finances/stmt.ts index e5891a4..f2ba08f 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -3,19 +3,13 @@ import { Command } from "commander"; import { PDFParse } from "pdf-parse"; import * as path from "node:path"; -import { type ParsePdfError, isParsePdfError } from "./parse_pdf_error.ts"; +import { isParsePdfError } from "./parse_pdf_error.ts"; import { identify, isIdentifyError } from "./identify.ts"; import { messageForError } from "./error.ts"; import { unreachable } from "./unreachable.ts"; -import { rogersBill, type ParsedRogersBill } from "./rogers.ts"; +import { rogersBill } from "./rogers.ts"; +import { publicMobileStatement } from "./public_mobile.ts"; import { - publicMobileStatement, - type ParsedPublicMobileStatement, -} from "./public_mobile.ts"; -import { - type ParsedQuestradeMarginStatement, - type ParsedQuestradeRESPStatement, - type ParsedQuestradeRRSPStatement, questradeMarginStatement, questradeRESPStatement, questradeRRSPStatement, @@ -74,42 +68,6 @@ async function readPdf( return { text, lines, hash }; } -type PdfType = - | "PublicMobileStatement" - | "QuestradeRESPStatement" - | "QuestradeRRSPStatement" - | "QuestradeMarginStatement" - | "RogersBill"; - -type ParsedPdf = - | ParsedQuestradeMarginStatement - | ParsedQuestradeRESPStatement - | ParsedQuestradeRRSPStatement - | ParsedPublicMobileStatement - | ParsedRogersBill; - -function parsePdf(pdfLines: string[]): ParsedPdf | ParsePdfError { - const type = identify(pdfLines); - if (isIdentifyError(type)) { - const { message } = type; - return { type: "ParsePdfError", message }; - } else if (!type) { - return { type: "ParsePdfError", message: "unrecognized pdf content" }; - } else if (type === "PublicMobileStatement") { - return publicMobileStatement.parse(pdfLines); - } else if (type === "QuestradeRESPStatement") { - return questradeRESPStatement.parse(pdfLines); - } else if (type === "QuestradeRRSPStatement") { - return questradeRRSPStatement.parse(pdfLines); - } else if (type === "QuestradeMarginStatement") { - return questradeMarginStatement.parse(pdfLines); - } else if (type === "RogersBill") { - return rogersBill.parse(pdfLines); - } else { - unreachable(type, "unknown type"); - } -} - function calculateFileName(parsedPdf: ParsedPdf): string { if (parsedPdf.type === "PublicMobileStatement") { return publicMobileStatement.calculateFileName(parsedPdf); @@ -278,25 +236,34 @@ async function parseCommand( return; } - const text = await readPdf(filePath); - if (isReadPdfError(text)) { - console.error(`ERROR: ${text.message}: ${filePath}`); + 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 parsedPdf = parsePdf(text.lines); - if (isParsePdfError(parsedPdf)) { - console.error( - `ERROR: unable to parse pdf contents: ` + - `${parsedPdf.message}: ${filePath}`, - ); + const parseResult = document.parse(readPdfResult.lines); + if (isParsePdfError(parseResult)) { + const { message } = parseResult; + console.error(`ERROR: ${message}: ${filePath}`); process.exit(1); } if (options?.v) { console.log(filePath); } - console.log(parsedPdf); + console.log(parseResult); } interface IdentifyOptions { From fb15f148ad773aac630d7212deef2b1f79ddef98 Mon Sep 17 00:00:00 2001 From: denver Date: Fri, 19 Jun 2026 00:49:27 -0400 Subject: [PATCH 09/14] Fix filename --- finances/stmt.ts | 49 +++++++++++++++++++++--------------------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/finances/stmt.ts b/finances/stmt.ts index f2ba08f..6703d90 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -6,14 +6,6 @@ import * as path from "node:path"; import { isParsePdfError } from "./parse_pdf_error.ts"; import { identify, isIdentifyError } from "./identify.ts"; import { messageForError } from "./error.ts"; -import { unreachable } from "./unreachable.ts"; -import { rogersBill } from "./rogers.ts"; -import { publicMobileStatement } from "./public_mobile.ts"; -import { - questradeMarginStatement, - questradeRESPStatement, - questradeRRSPStatement, -} from "./questrade.ts"; const program = new Command(); @@ -68,22 +60,6 @@ async function readPdf( return { text, lines, hash }; } -function calculateFileName(parsedPdf: ParsedPdf): string { - if (parsedPdf.type === "PublicMobileStatement") { - return publicMobileStatement.calculateFileName(parsedPdf); - } else if (parsedPdf.type === "RogersBill") { - return rogersBill.calculateFileName(parsedPdf); - } else if (parsedPdf.type === "QuestradeRESPStatement") { - return questradeRESPStatement.calculateFileName(parsedPdf); - } else if (parsedPdf.type === "QuestradeRRSPStatement") { - return questradeRRSPStatement.calculateFileName(parsedPdf); - } else if (parsedPdf.type === "QuestradeMarginStatement") { - return questradeMarginStatement.calculateFileName(parsedPdf); - } else { - unreachable(parsedPdf.type, "unknown type"); - } -} - interface CalculateFileNamesError { type: "CalculateFileNamesError"; message: string; @@ -126,16 +102,33 @@ async function calculateFileNames( }; } - const parsedPdf = parsePdf(readPdfResult.lines); - if (isParsePdfError(parsedPdf)) { + 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 (isParsePdfError(parseResult)) { return { type: "CalculateFileNamesError", - message: `unable to parse pdf contents: ${parsedPdf.message}`, + message: parseResult.message, filePath, }; } - const fileName = calculateFileName(parsedPdf); + // 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 }; From ad69000d157ed1710918bcd51ab03376989723d2 Mon Sep 17 00:00:00 2001 From: denver Date: Fri, 19 Jun 2026 00:54:58 -0400 Subject: [PATCH 10/14] refactor --- finances/document.ts | 22 ++++++++++++++++++---- finances/parse_pdf_error.ts | 15 --------------- finances/public_mobile.ts | 15 ++++++++------- finances/questrade.ts | 16 +++++++++------- finances/rogers.ts | 21 +++++++++++++-------- finances/stmt.ts | 6 +++--- 6 files changed, 51 insertions(+), 44 deletions(-) delete mode 100644 finances/parse_pdf_error.ts diff --git a/finances/document.ts b/finances/document.ts index c950ae3..89f3ddd 100644 --- a/finances/document.ts +++ b/finances/document.ts @@ -1,18 +1,32 @@ -import { type ParsePdfError } from "./parse_pdf_error.ts"; +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< - ParsedPdf extends ParsedDocument, + ParsedDocumentT extends ParsedDocument, TypeName extends string, > { readonly type: TypeName; identify(lines: readonly string[]): boolean; - parse(lines: readonly string[]): ParsedPdf | ParsePdfError; + parse(lines: readonly string[]): ParsedDocumentT | DocumentParseError; - calculateFileName(pdf: Readonly): string; + calculateFileName(pdf: Readonly): string; } diff --git a/finances/parse_pdf_error.ts b/finances/parse_pdf_error.ts deleted file mode 100644 index d26bdc2..0000000 --- a/finances/parse_pdf_error.ts +++ /dev/null @@ -1,15 +0,0 @@ -export interface ParsePdfError { - type: "ParsePdfError"; - message: string; -} - -export 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" - ); -} diff --git a/finances/public_mobile.ts b/finances/public_mobile.ts index 10a018e..71e88f3 100644 --- a/finances/public_mobile.ts +++ b/finances/public_mobile.ts @@ -1,5 +1,4 @@ -import { type Document } from "./document.ts"; -import { type ParsePdfError } from "./parse_pdf_error.ts"; +import { type Document, type DocumentParseError } from "./document.ts"; import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; export interface ParsedPublicMobileStatement { @@ -23,17 +22,19 @@ class PublicMobileStatement implements Document< return `${invoiceDate} Public Mobile Payment ${totalAmountPaid}.pdf`; } - parse(lines: readonly string[]): ParsedPublicMobileStatement | ParsePdfError { + parse( + lines: readonly string[], + ): ParsedPublicMobileStatement | DocumentParseError { const invoiceIndex = lines.findIndex( (line) => line.toLowerCase() === "invoice", ); if (invoiceIndex < 0) { - return { type: "ParsePdfError", message: "INVOICE line not found" }; + return { type: "DocumentParseError", message: "INVOICE line not found" }; } const invoiceDateStr = lines[invoiceIndex + 1]?.trim(); if (!invoiceDateStr) { return { - type: "ParsePdfError", + type: "DocumentParseError", message: "expected line after INVOICE line", }; } @@ -41,7 +42,7 @@ class PublicMobileStatement implements Document< if (isParseDateError(invoiceDate)) { const { message } = invoiceDate; return { - type: "ParsePdfError", + type: "DocumentParseError", message: `unable to parse invoice date: ${invoiceDateStr} (${message})`, }; } @@ -51,7 +52,7 @@ class PublicMobileStatement implements Document< ); if (!totalAmountPaidLine) { return { - type: "ParsePdfError", + type: "DocumentParseError", message: "Total Amount Paid line not found", }; } diff --git a/finances/questrade.ts b/finances/questrade.ts index 7728821..238376c 100644 --- a/finances/questrade.ts +++ b/finances/questrade.ts @@ -1,5 +1,4 @@ -import { type Document } from "./document.ts"; -import { type ParsePdfError } from "./parse_pdf_error.ts"; +import { type Document, type DocumentParseError } from "./document.ts"; import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; export type StatementType = @@ -71,14 +70,14 @@ class QuestradeStatement implements Document< parse( lines: readonly string[], - ): ParsedQuestradeStatement | ParsePdfError { + ): ParsedQuestradeStatement | DocumentParseError { const accountNumberRegex = /Account\s*#:\s*(\d+)/i; const accountNumberLine = lines.find((line) => accountNumberRegex.test(line), ); if (!accountNumberLine) { return { - type: "ParsePdfError", + type: "DocumentParseError", message: "Account number line not found", }; } @@ -94,7 +93,10 @@ class QuestradeStatement implements Document< line.match(currentMonthRegex), ); if (!currentMonthLine) { - return { type: "ParsePdfError", message: "Current month line not found" }; + return { + type: "DocumentParseError", + message: "Current month line not found", + }; } const statementDateStr = currentMonthLine.match(currentMonthRegex)?.[1]; if (!statementDateStr) { @@ -106,7 +108,7 @@ class QuestradeStatement implements Document< if (isParseDateError(statementDate)) { const { message } = statementDate; return { - type: "ParsePdfError", + type: "DocumentParseError", message: `unable to parse statement date: ${statementDateStr} (${message})`, }; } @@ -114,7 +116,7 @@ class QuestradeStatement implements Document< const balanceRegex = /Current month balance:\s*(\$[\d,.]+)/i; const balanceLine = lines.find((line) => line.match(balanceRegex)); if (!balanceLine) { - return { type: "ParsePdfError", message: "Balance line not found" }; + return { type: "DocumentParseError", message: "Balance line not found" }; } const balance = balanceLine.match(balanceRegex)?.[1]; if (!balance) { diff --git a/finances/rogers.ts b/finances/rogers.ts index e02fff8..c95248f 100644 --- a/finances/rogers.ts +++ b/finances/rogers.ts @@ -1,5 +1,4 @@ -import { type Document } from "./document.ts"; -import { type ParsePdfError } from "./parse_pdf_error.ts"; +import { type Document, type DocumentParseError } from "./document.ts"; import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; export interface ParsedRogersBill { @@ -20,17 +19,20 @@ class RogersBill implements Document { return `${billDate} Rogers Bill ${amountDue}.pdf`; } - parse(lines: readonly string[]): ParsedRogersBill | ParsePdfError { + parse(lines: readonly string[]): ParsedRogersBill | DocumentParseError { const amountDueIndex = lines.findIndex( (line) => line.toLowerCase() === "what is the total due?", ); if (amountDueIndex < 0) { - return { type: "ParsePdfError", message: "amount due line not found" }; + return { + type: "DocumentParseError", + message: "amount due line not found", + }; } const amountDue = lines[amountDueIndex + 1]?.trim(); if (!amountDue) { return { - type: "ParsePdfError", + type: "DocumentParseError", message: "expected line after amount due line", }; } @@ -39,12 +41,15 @@ class RogersBill implements Document { (line) => line.toLowerCase() === "bill date", ); if (billDateIndex < 0) { - return { type: "ParsePdfError", message: "bill date line not found" }; + return { + type: "DocumentParseError", + message: "bill date line not found", + }; } const billDateStr = lines[billDateIndex + 1]?.trim(); if (!billDateStr) { return { - type: "ParsePdfError", + type: "DocumentParseError", message: "expected line after bill date line", }; } @@ -52,7 +57,7 @@ class RogersBill implements Document { if (isParseDateError(billDate)) { const { message } = billDate; return { - type: "ParsePdfError", + type: "DocumentParseError", message: `unable to parse invoice date: ${billDateStr} (${message})`, }; } diff --git a/finances/stmt.ts b/finances/stmt.ts index 6703d90..588dd8c 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -3,7 +3,7 @@ import { Command } from "commander"; import { PDFParse } from "pdf-parse"; import * as path from "node:path"; -import { isParsePdfError } from "./parse_pdf_error.ts"; +import { isDocumentParseError } from "./document.ts"; import { identify, isIdentifyError } from "./identify.ts"; import { messageForError } from "./error.ts"; @@ -119,7 +119,7 @@ async function calculateFileNames( } const parseResult = document.parse(readPdfResult.lines); - if (isParsePdfError(parseResult)) { + if (isDocumentParseError(parseResult)) { return { type: "CalculateFileNamesError", message: parseResult.message, @@ -247,7 +247,7 @@ async function parseCommand( } const parseResult = document.parse(readPdfResult.lines); - if (isParsePdfError(parseResult)) { + if (isDocumentParseError(parseResult)) { const { message } = parseResult; console.error(`ERROR: ${message}: ${filePath}`); process.exit(1); From 0988cc4df0c3ec137c8f9777ba568b3805021ad9 Mon Sep 17 00:00:00 2001 From: denver Date: Fri, 19 Jun 2026 00:56:44 -0400 Subject: [PATCH 11/14] pdf_read.ts added --- finances/pdf_read.ts | 55 ++++++++++++++++++++++++++++++++++++++++++++ finances/stmt.ts | 54 +------------------------------------------ 2 files changed, 56 insertions(+), 53 deletions(-) create mode 100644 finances/pdf_read.ts 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 index 588dd8c..c69a59f 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -1,65 +1,13 @@ import * as fs from "node:fs/promises"; import { Command } from "commander"; -import { PDFParse } from "pdf-parse"; import * as path from "node:path"; +import { isReadPdfError, readPdf } from "./pdf_read.ts"; import { isDocumentParseError } from "./document.ts"; import { identify, isIdentifyError } from "./identify.ts"; -import { messageForError } from "./error.ts"; const program = new Command(); -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 }; -} - interface CalculateFileNamesError { type: "CalculateFileNamesError"; message: string; From d073dcb3b8fc195717e8ac96a90dc0b418682ab1 Mon Sep 17 00:00:00 2001 From: denver Date: Fri, 19 Jun 2026 00:59:06 -0400 Subject: [PATCH 12/14] calculate_file_names.ts added --- finances/calculate_file_names.ts | 132 +++++++++++++++++++++++++++++++ finances/stmt.ts | 131 +----------------------------- 2 files changed, 136 insertions(+), 127 deletions(-) create mode 100644 finances/calculate_file_names.ts 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/stmt.ts b/finances/stmt.ts index c69a59f..1575ba0 100644 --- a/finances/stmt.ts +++ b/finances/stmt.ts @@ -5,136 +5,13 @@ import * as path from "node:path"; import { isReadPdfError, readPdf } from "./pdf_read.ts"; import { isDocumentParseError } from "./document.ts"; import { identify, isIdentifyError } from "./identify.ts"; +import { + calculateFileNames, + isCalculateFileNamesError, +} from "./calculate_file_names.ts"; const program = new Command(); -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 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; -} - interface PrintOptions { v?: boolean; } From 5c64865ae23798537c1e5e7469419fa98d6e968c Mon Sep 17 00:00:00 2001 From: denver Date: Fri, 19 Jun 2026 01:06:10 -0400 Subject: [PATCH 13/14] moved commands into individual files --- finances/commands/filename.ts | 41 +++++ finances/commands/identify.ts | 39 +++++ finances/commands/parse.ts | 48 ++++++ finances/commands/print.ts | 28 ++++ finances/commands/rename.ts | 72 +++++++++ finances/index.ts | 56 +++++++ finances/stmt.ts | 273 ---------------------------------- 7 files changed, 284 insertions(+), 273 deletions(-) create mode 100644 finances/commands/filename.ts create mode 100644 finances/commands/identify.ts create mode 100644 finances/commands/parse.ts create mode 100644 finances/commands/print.ts create mode 100644 finances/commands/rename.ts create mode 100644 finances/index.ts delete mode 100644 finances/stmt.ts 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/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/stmt.ts b/finances/stmt.ts deleted file mode 100644 index 1575ba0..0000000 --- a/finances/stmt.ts +++ /dev/null @@ -1,273 +0,0 @@ -import * as fs from "node:fs/promises"; -import { Command } from "commander"; -import * as path from "node:path"; - -import { isReadPdfError, readPdf } from "./pdf_read.ts"; -import { isDocumentParseError } from "./document.ts"; -import { identify, isIdentifyError } from "./identify.ts"; -import { - calculateFileNames, - isCalculateFileNamesError, -} from "./calculate_file_names.ts"; - -const program = new Command(); - -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 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); -} - -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 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); - } -} - -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); From dac6913fb97ca3dc8f978e9296e111353e73d211 Mon Sep 17 00:00:00 2001 From: denver Date: Fri, 19 Jun 2026 01:07:55 -0400 Subject: [PATCH 14/14] movde documents into subdir --- finances/documents.ts | 6 +++--- finances/{ => documents}/public_mobile.ts | 4 ++-- finances/{ => documents}/questrade.ts | 4 ++-- finances/{ => documents}/rogers.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) rename finances/{ => documents}/public_mobile.ts (93%) rename finances/{ => documents}/questrade.ts (97%) rename finances/{ => documents}/rogers.ts (93%) diff --git a/finances/documents.ts b/finances/documents.ts index 1ae4d7f..8625d7b 100644 --- a/finances/documents.ts +++ b/finances/documents.ts @@ -1,10 +1,10 @@ -import { rogersBill } from "./rogers.ts"; -import { publicMobileStatement } from "./public_mobile.ts"; +import { rogersBill } from "./documents/rogers.ts"; +import { publicMobileStatement } from "./documents/public_mobile.ts"; import { questradeMarginStatement, questradeRESPStatement, questradeRRSPStatement, -} from "./questrade.ts"; +} from "./documents/questrade.ts"; export const allDocuments = Object.freeze([ publicMobileStatement, diff --git a/finances/public_mobile.ts b/finances/documents/public_mobile.ts similarity index 93% rename from finances/public_mobile.ts rename to finances/documents/public_mobile.ts index 71e88f3..01c0058 100644 --- a/finances/public_mobile.ts +++ b/finances/documents/public_mobile.ts @@ -1,5 +1,5 @@ -import { type Document, type DocumentParseError } from "./document.ts"; -import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; +import { type Document, type DocumentParseError } from "../document.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "../date.ts"; export interface ParsedPublicMobileStatement { type: "PublicMobileStatement"; diff --git a/finances/questrade.ts b/finances/documents/questrade.ts similarity index 97% rename from finances/questrade.ts rename to finances/documents/questrade.ts index 238376c..33c579a 100644 --- a/finances/questrade.ts +++ b/finances/documents/questrade.ts @@ -1,5 +1,5 @@ -import { type Document, type DocumentParseError } from "./document.ts"; -import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; +import { type Document, type DocumentParseError } from "../document.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "../date.ts"; export type StatementType = | "QuestradeRESPStatement" diff --git a/finances/rogers.ts b/finances/documents/rogers.ts similarity index 93% rename from finances/rogers.ts rename to finances/documents/rogers.ts index c95248f..67fd071 100644 --- a/finances/rogers.ts +++ b/finances/documents/rogers.ts @@ -1,5 +1,5 @@ -import { type Document, type DocumentParseError } from "./document.ts"; -import { parseDateToYYYYMMDD, isParseDateError } from "./date.ts"; +import { type Document, type DocumentParseError } from "../document.ts"; +import { parseDateToYYYYMMDD, isParseDateError } from "../date.ts"; export interface ParsedRogersBill { type: "RogersBill";