Skip to content
132 changes: 132 additions & 0 deletions finances/calculate_file_names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { isReadPdfError, readPdf } from "./pdf_read.ts";
import { isDocumentParseError } from "./document.ts";
import { identify, isIdentifyError } from "./identify.ts";
Comment on lines +2 to +3

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Import 'Document' and 'ParsedDocument' types to enable a fully type-safe helper function for parsing and calculating file names, avoiding unsafe 'any' casts.

Suggested change
import { isDocumentParseError } from "./document.ts";
import { identify, isIdentifyError } from "./identify.ts";
import { type Document, isDocumentParseError, type ParsedDocument } 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<Map<string, string> | CalculateFileNamesError> {
const infoByFileName = new Map<
string,
Array<{ filePath: string; hash: string }>
>();
const fileNameByFilePath = new Map<string, string>();

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);
Comment on lines +65 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of using an unsafe 'as unknown as any' cast and disabling ESLint rules, we can use a generic helper function 'parseAndCalculate' to parse and calculate the file name in a 100% type-safe manner.

Suggested change
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 fileName = parseAndCalculate(document, readPdfResult.lines);
if (isDocumentParseError(fileName)) {
return {
type: "CalculateFileNamesError",
message: fileName.message,
filePath,
};
}
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<string>();
for (const info of infoList) {
hashes.add(info.hash);
}

if (hashes.size < 2) {
continue;
}

const numberByHash = new Map<string, number>();
for (const hash of hashes) {
numberByHash.set(hash, numberByHash.size + 1);
}
Comment on lines +97 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Sorting the hashes before assigning numbers ensures that the numbering is completely deterministic and independent of the order in which the files are passed to the command.

Suggested change
const numberByHash = new Map<string, number>();
for (const hash of hashes) {
numberByHash.set(hash, numberByHash.size + 1);
}
const numberByHash = new Map<string, number>();
const sortedHashes = Array.from(hashes).sort();
for (const hash of sortedHashes) {
numberByHash.set(hash, numberByHash.size + 1);
}


const fileNameByHash = new Map<string, string>();
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;
}
Comment on lines +131 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Define the generic helper function 'parseAndCalculate' to safely handle the parsing and file name calculation without losing type safety.

Suggested change
return fileNameByFilePath;
}
return fileNameByFilePath;
}
function parseAndCalculate<
ParsedDocumentT extends ParsedDocument<TypeName>,
TypeName extends string,
>(
document: Document<ParsedDocumentT, TypeName>,
lines: readonly string[],
): string | DocumentParseError {
const parseResult = document.parse(lines);
if (isDocumentParseError(parseResult)) {
return parseResult;
}
return document.calculateFileName(parseResult);
}

41 changes: 41 additions & 0 deletions finances/commands/filename.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
}
39 changes: 39 additions & 0 deletions finances/commands/identify.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 ?? "<unknown>";

if (options?.v) {
console.log(`${filePath}: ${type}`);
} else {
console.log(type);
}
}
48 changes: 48 additions & 0 deletions finances/commands/parse.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
28 changes: 28 additions & 0 deletions finances/commands/print.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
72 changes: 72 additions & 0 deletions finances/commands/rename.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<string>();
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);
Comment on lines +61 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

To prevent accidental data loss, check if the destination file already exists before renaming. 'fs.rename' will silently overwrite the target file if it exists, which can lead to lost files if multiple source files map to the same name or if a file with that name was already present.

    let destExists = false;
    try {
      await fs.access(destFilePath);
      destExists = true;
    } catch {
      // Destination does not exist, safe to rename
    }

    if (destExists) {
      console.error(
        "ERROR: Destination file already exists, skipping to prevent overwrite: " + destFilePath,
      );
      skippedCount++;
      continue;
    }

    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)`);
}
Loading
Loading