UNPKG

expenses-tracker-cli

Version:

šŸ’ø Take control of your finances from the command line with a powerful expense tracker featuring safe undo/redo, smart currency handling, typo correction, and beautiful PDF/CSV exports. šŸ“ˆ

1,546 lines (1,390 loc) • 95.2 kB
#!/usr/bin/env node import chalk from "chalk"; import { execSync } from "child_process"; import { Command } from "commander"; import fs from "fs"; import { createRequire } from "module"; import os from "os"; import path from "path"; import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; import readline from "readline"; import { fileURLToPath } from "url"; // Declare the program at the top const program = new Command(); // --- THE KEY CHANGE: Disable Commander's automatic help option for all commands --- program.helpOption(false); // This stops Commander from adding -h, --help to all commands. program.addHelpCommand(false); // <--- ADDED: Prevents Commander from creating the 'help' command entry // Define command examples - NEWLY ADDED BLOCK const commandExamples = { add: 'expense add 50 "Groceries for the week" --currency USD', "change-currency": "expense change-currency --currency EUR", edit: 'expense edit 12 --amount 75.50 --description "Updated item" --currency GBP --date 2025-07-30', reset: "expense reset", delete: "expense delete 5", recover: "expense recover 5", list: "expense list --month 7 --year 2025 --reindex", total: "expense total --month 7 --year 2025 --all", export: "expense export --pdf --month 7 --open", manual: "expense manual --open", // Example for the manual command itself undo: "expense undo", redo: "expense redo", }; // --- START: PRE-PARSING INTERCEPTION LOGIC FOR ALL COMMANDS --- // This block runs BEFORE Commander's program.parse() const rawArgs = process.argv.slice(2); // Get arguments excluding 'node' and 'expense.js' const potentialCommand = rawArgs[0]; // The first argument, which could be a command or flag // Helper function for Levenshtein Distance (remains unchanged) function calculateLevenshteinDistance(s1, s2) { s1 = s1.toLowerCase(); s2 = s2.toLowerCase(); const costs = []; for (let i = 0; i <= s1.length; i++) { let lastValue = i; for (let j = 0; j <= s2.length; j++) { if (i === 0) { costs[j] = j; } else if (j > 0) { let newValue = costs[j - 1]; if (s1.charAt(i - 1) !== s2.charAt(j - 1)) { newValue = Math.min(newValue, lastValue, costs[j]) + 1; } costs[j - 1] = lastValue; lastValue = newValue; } } if (i > 0) { costs[s2.length] = lastValue; } } return costs[s2.length]; } // Function to provide command suggestions based on Levenshtein distance (remains unchanged) function suggestCommand(unknownCmd, commandList) { let closestMatch = null; let minDistance = Infinity; const threshold = 2; // Adjust for more/less aggressive suggestions for (const cmd of commandList) { const distance = calculateLevenshteinDistance(unknownCmd, cmd); if (distance < minDistance && distance <= threshold) { minDistance = distance; closestMatch = cmd; } } return closestMatch; } // --- IMPORTANT: Manually list your commands and their aliases here --- // This list needs to be kept in sync with your program.command definitions. const knownCommandsAndAliases = new Set([ "add", "a", "change-currency", "edit", "reset", "delete", "d", "recover", "list", "l", "total", "t", "export", "x", "manual", // <--- ADDED: Include the new manual command "undo", // ADDED: undo command "redo", // ADDED: redo command ]); // Check if a command was provided as the first argument if (potentialCommand) { // Define global flags that Commander itself should handle directly, // preventing our custom unknown command logic from interfering. const globalCommanderFlags = new Set(["--help", "-h", "--version", "-v"]); // If the potentialCommand is NOT a known command/alias // AND it's NOT one of the global flags that Commander should handle directly if ( !knownCommandsAndAliases.has(potentialCommand) && !globalCommanderFlags.has(potentialCommand) ) { // This block handles 'expense <typo>' OR 'expense <typo> --help' // since we're now letting Commander handle valid --help/--version on their own. const suggestion = suggestCommand( potentialCommand, Array.from(knownCommandsAndAliases), ); if (suggestion) { console.error( chalk.red( `\nāŒ Error: Unknown command '${potentialCommand}'. Did you mean ${chalk.bold( suggestion, )}?`, ), ); console.error( chalk.blue( `For help with '${suggestion}', type: ${chalk.bold( `expense ${suggestion} --help`, )}`, ), ); } else { console.error( chalk.red(`\nāŒ Error: Unknown command '${potentialCommand}'.`), ); console.error( chalk.blue( `\nFor a list of available commands, type: ${chalk.bold( "expense --help", )}`, ), ); } process.exit(1); // Exit if it's a completely unknown command or an unknown command with a help flag } // If potentialCommand IS a known command, or if it's a global help/version flag, // then we let Commander's program.parse() handle it. } // --- END: PRE-PARSING INTERCEPTION LOGIC --- // --- IMPORTANT: This configuration allows tests to use a temporary directory --- const dataDir = process.env.EXPENSE_DATA_DIR || path.join(os.homedir(), ".expense"); const dataFile = path.join(dataDir, "data.json"); const configFile = path.join(dataDir, "config.json"); // NEW: Undo and Redo stack files const undoStackFile = path.join(dataDir, "undoStack.json"); // ADDED const redoStackFile = path.join(dataDir, "redoStack.json"); // ADDED // Define __dirname for consistent path resolution in ES Modules (remains unchanged) const __filename = fileURLToPath(import.meta.url); const __dirname = path.join(path.dirname(__filename)); // Use createRequire to load all-currencies.json (remains unchanged) const require = createRequire(import.meta.url); const allCurrencies = require(path.join(__dirname, "all-currencies.json")); // Ensure data file and config file exist (remains unchanged) function ensureDataFile() { if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); if (!fs.existsSync(dataFile)) fs.writeFileSync(dataFile, "[]", "utf-8"); if (!fs.existsSync(configFile)) fs.writeFileSync(configFile, "{}", "utf-8"); // NEW: Ensure undo and redo stack files exist if (!fs.existsSync(undoStackFile)) fs.writeFileSync(undoStackFile, "[]", "utf-8"); if (!fs.existsSync(redoStackFile)) fs.writeFileSync(redoStackFile, "[]", "utf-8"); } // Helper function to read config (get saved currency) (remains unchanged) function getConfig() { if (fs.existsSync(configFile)) { try { const configData = fs.readFileSync(configFile, "utf-8"); // Handle case where file is empty const config = configData ? JSON.parse(configData) : {}; if (!config.currencyHistory) { config.currencyHistory = []; } // Explicitly check for undefined to distinguish from a null value that might be set intentionally if (config.preferredCurrency === undefined) { config.preferredCurrency = null; // Set to null if it doesn't exist } return config; } catch { console.error( chalk.red("āŒ Error reading config file. Creating a new one."), ); fs.writeFileSync(configFile, "{}", "utf-8"); return { preferredCurrency: null, currencyHistory: [] }; } } return { preferredCurrency: null, currencyHistory: [] }; } // Helper function to save config (store currency) (remains unchanged) function saveConfig(config) { fs.writeFileSync(configFile, JSON.stringify(config, null, 2), "utf-8"); } // --- Currency Validation Helpers --- (remains unchanged) function isValidCurrencyCode(code) { return Object.prototype.hasOwnProperty.call(allCurrencies, code); } function getClosestCurrencyMatch(inputCode) { const upperInput = inputCode.toUpperCase(); if (isValidCurrencyCode(upperInput)) { return upperInput; } for (const code in allCurrencies) { if (allCurrencies[code].toUpperCase().includes(upperInput)) { return code; } } if (upperInput.length >= 2) { for (const code in allCurrencies) { if (code.startsWith(upperInput)) { return code; } } } return null; } // validateAndSuggestCurrency async function validateAndSuggestCurrency(currencyInput) { let validatedCurrency = null; let attempts = 0; const maxAttempts = 3; // Enforce 3 attempts while (validatedCurrency === null && attempts < maxAttempts) { let currentInput = currencyInput; if (attempts > 0) { stopLoadingMessage(); console.log( chalk.blue( `\nTo find correct 3-letter currency codes (ISO 4217), please check these reliable resources:`, ), ); console.log( ` ${chalk.underline.bold( "1. Thomson Reuters (ISO 4217 list): https://www.thomsonreuters.com/content/helpandsupp/en-us/help/legal-tracker/law-firm/international-currencies/list-of-currency-codes.html", )}`, ); console.log( ` ${chalk.underline.bold( "2. IBAN.com (Currency Codes by Country): https://www.iban.com/currency-codes", )}`, ); const openLinkConfirmation = await promptConfirmation( chalk.blue( "Would you like to open one of these links in your browser now?", ), ); if (openLinkConfirmation) { let linkChoice = ""; while (!["1", "2"].includes(linkChoice)) { const rlChoice = readline.createInterface({ input: process.stdin, output: process.stdout, }); linkChoice = await new Promise((resolve) => { rlChoice.question( chalk.cyan( "Enter the number of the link you want to open (1 or 2): ", ), (answer) => { rlChoice.close(); resolve(answer.trim()); }, ); }); if (!["1", "2"].includes(linkChoice)) { console.log(chalk.yellow("Invalid choice. Please enter 1 or 2.")); } } let urlToOpen; if (linkChoice === "1") urlToOpen = "https://www.thomsonreuters.com/content/helpandsupp/en-us/help/legal-tracker/law-firm/international-currencies/list-of-currency-codes.html"; else if (linkChoice === "2") urlToOpen = "https://www.iban.com/currency-codes"; if (urlToOpen) { openUrlInBrowser(urlToOpen); } } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); currentInput = await new Promise((resolve) => { rl.question( chalk.yellow( `\nPlease enter a valid currency code (Attempt ${ attempts + 1 }/${maxAttempts}): `, ), (answer) => { rl.close(); resolve(answer.trim()); }, ); }); } let upperInput = currentInput.toUpperCase(); if (isValidCurrencyCode(upperInput)) { validatedCurrency = upperInput; break; } stopLoadingMessage(); const suggestedCurrency = getClosestCurrencyMatch(upperInput); if (suggestedCurrency) { const confirmation = await promptConfirmation( chalk.yellow( `"${currentInput}" is not a standard code. Did you mean "${suggestedCurrency}" (${allCurrencies[suggestedCurrency]})?`, ), ); if (confirmation) { validatedCurrency = suggestedCurrency; break; } else { console.log(chalk.yellow("Suggestion not accepted.")); } } else { console.error( chalk.red( `āŒ "${currentInput}" is not a recognized currency code and no close match was found.`, ), ); } attempts++; } if (validatedCurrency === null && attempts >= maxAttempts) { console.error( chalk.red( `\nExceeded maximum attempts (${maxAttempts}). Please run the command again with a valid currency code.`, ), ); console.log( chalk.blue( `For a list of supported currencies, refer to ISO 4217 currency codes (e.g., USD, EUR, BDT).`, ), ); } return validatedCurrency; } // Main function to change preferred currency and optionally convert past expenses (remains unchanged) async function changeCurrency( newCurrency, convertPast = false, exchangeRate = null, ) { const config = getConfig(); const oldPreferredCurrency = config.preferredCurrency; if (oldPreferredCurrency !== newCurrency) { if (oldPreferredCurrency !== null) { const historyEntry = { date: new Date().toISOString(), previousPreferredCurrency: oldPreferredCurrency, newPreferredCurrency: newCurrency, exchangeRate: exchangeRate, }; config.currencyHistory.push(historyEntry); } config.preferredCurrency = newCurrency; saveConfig(config); console.log( chalk.green(`āœ… Preferred currency changed to ${newCurrency}.`), ); if ( convertPast && oldPreferredCurrency !== null && oldPreferredCurrency !== newCurrency ) { await convertPastExpenses( oldPreferredCurrency, newCurrency, exchangeRate, ); } else if (oldPreferredCurrency === null) { console.log( chalk.blue( `ā„¹ļø This is the first time setting a preferred currency. No past expenses to convert.`, ), ); } } else { console.log( chalk.yellow( `ā„¹ļø Preferred currency is already set to ${newCurrency}. No change needed.`, ), ); } } // Function to convert past expenses to the new preferred currency (remains unchanged) async function convertPastExpenses( oldPreferredCurrency, newPreferredCurrency, exchangeRate, ) { console.log( chalk.yellow(`\nšŸ”„ Converting past expenses to ${newPreferredCurrency}...`), ); let expenses = readExpenses(); let convertedCount = 0; if (exchangeRate === null) { console.warn( chalk.yellow( `āš ļø No exchange rate provided. Expenses previously in ${oldPreferredCurrency} will be marked with ${newPreferredCurrency}, but their amounts will remain unchanged.`, ), ); } for (const expense of expenses) { expense.originalAmount = expense.originalAmount ?? expense.amount; expense.originalCurrency = expense.originalCurrency ?? expense.currency; // Only convert if the expense's current currency matches the old preferred currency if (expense.currency === oldPreferredCurrency) { if (exchangeRate !== null) { expense.amount = expense.originalAmount * exchangeRate; // Use originalAmount for precise conversion } expense.currency = newPreferredCurrency; convertedCount++; } } writeExpenses(expenses); console.log( chalk.green( `āœ… Processed ${convertedCount} past expenses to align with ${newPreferredCurrency}.`, ), ); } // Generate export labels for filename and PDF title function generateExportLabels(options) { let baseLabel = ""; // Helper variables for cleaner label construction const monthName = options.month ? getMonthName(options.month) : null; const yearLabelSuffix = options.year ? `_${options.year}` : "_AllYears"; // No need for monthLabelPrefix as we construct it dynamically now if (options.date) { baseLabel = options.date; // Most specific: exact date } else if (options.week && options.month) { // e.g., "5thWeek_July_2025" or "5thWeek_July_AllYears" // This implicitly requires year for full date context if not explicitly AllYears baseLabel = `${getWeekSuffix(options.week)}_${monthName}${yearLabelSuffix}`; } else if (options.day && options.month) { // NEW: Handle day + month combination // e.g., "Wednesday_July_2025" or "Wednesday_July_AllYears" baseLabel = `${options.day}_${monthName}${yearLabelSuffix}`; } else if (options.day && options.year) { // NEW: Handle day + year combination (e.g., Wednesday_AllMonths_2024) baseLabel = `${options.day}_AllMonths_${options.year}`; } else if (options.month) { // e.g., "July_2025" or "July_AllYears" baseLabel = `${monthName}${yearLabelSuffix}`; } else if (options.day) { // e.g., "Wednesday_AllMonths_AllYears" baseLabel = `${options.day}_AllMonths_AllYears`; } else if (options.year) { // e.g., "2025" (most general time filter) baseLabel = options.year; } if (!baseLabel) { baseLabel = "AllTime"; // Fallback if no specific filter is applied } return { filenameLabel: `Expense_${baseLabel}`, titleLabel: baseLabel, }; } // --- Week of the Month Calculation (Original Logic) --- (remains unchanged) function getWeekOfMonth(date) { const firstDayOfMonth = new Date(date.getFullYear(), date.getMonth(), 1); const daysFromStartOfMonth = Math.floor( (date - firstDayOfMonth) / (1000 * 60 * 60 * 24), ); return Math.ceil((daysFromStartOfMonth + 1) / 7); } // --- Helper for Week Suffix (1st, 2nd, 3rd, 4th) --- (remains unchanged) function getWeekSuffix(weekNumber) { const num = parseInt(weekNumber, 10); if (isNaN(num)) return String(weekNumber); // Return as is if not a number if (num === 1) return "1stWeek"; if (num === 2) return "2ndWeek"; if (num === 3) return "3rdWeek"; return `${num}thWeek`; } // Convert month number to month name (remains unchanged) function getMonthName(month) { const monthNames = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; return monthNames[month - 1]; } // Helper function to compare dates without time (remains unchanged) function isSameDate(date1, date2) { return ( date1.getFullYear() === date2.getFullYear() && date1.getMonth() === date2.getMonth() && date1.getDate() === date2.getDate() ); } // Helper function to format date for CSV/PDF (remains unchanged) function formatDate(isoString) { const date = new Date(isoString); return date.toLocaleDateString("en-GB", { year: "numeric", month: "2-digit", day: "2-digit", }); } // Ensure that the data and config files exist (remains unchanged) ensureDataFile(); // Function to read expenses from the data file (remains unchanged) function readExpenses() { const data = fs.readFileSync(dataFile, "utf-8"); return JSON.parse(data); } // Function to write expenses to the data file (remains unchanged) function writeExpenses(expenses) { fs.writeFileSync(dataFile, JSON.stringify(expenses, null, 2), "utf-8"); } // Function to prompt for confirmation (remains unchanged) function promptConfirmation(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question(question + " (y/N): ", (answer) => { rl.close(); resolve(answer.trim().toLowerCase() === "y"); }); }); } // Function to get the downloads folder path (remains unchanged) function getDownloadsFolder() { const platform = process.platform; if (platform === "win32") { try { let rawPath = execSync( "powershell -command \"(Get-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders').'{374DE290-123F-4565-9164-39C4925E467B}'\"", { encoding: "utf-8" }, ).trim(); rawPath = rawPath.replace(/^%USERPROFILE%/, os.homedir()); return path.normalize(rawPath); } catch { return path.join(os.homedir(), "Downloads"); } } else if (platform === "darwin") { return path.join(os.homedir(), "Downloads"); } else if (platform === "linux") { return path.join(os.homedir(), "Downloads"); } return path.join(os.homedir(), "Downloads"); } // Function to open the exported file (remains unchanged) function openFile(filePath) { const platform = process.platform; let command; if (platform === "win32") { command = `start "" "${filePath}"`; } else if (platform === "darwin") { command = `open "${filePath}"`; } else if (platform === "linux") { command = `xdg-open "${filePath}"`; } else { console.warn( chalk.yellow("Automatic file opening not supported on this platform."), ); return; } console.log(chalk.blue(`\nšŸ“‚ Opening the file automatically...`)); try { execSync(command, { stdio: "ignore" }); } catch (err) { console.error(chalk.red(`Failed to open file: ${filePath}`), err); } } // Function to filter expenses by date and other filters function filterExpenses(expenses, filters, includeDeleted = false) { return expenses.filter((e) => { // 1. Filter out deleted expenses if --all is not used if (!includeDeleted && e.isDeleted) { return false; } const date = new Date(e.date); // Convert expense date string to Date object once // 2. Filter by specific date (YYYY-MM-DD) // No need for warnings here; assume validation happens in command action if (filters.date) { const filterDate = new Date(filters.date); if (!isSameDate(date, filterDate)) return false; } // 3. Filter by day name (e.g., Monday) if (filters.day) { const dayName = date.toLocaleDateString("en-US", { weekday: "long" }); if (dayName.toLowerCase() !== filters.day.toLowerCase()) return false; } // 4. Handle month and year filtering (consolidated logic) // This section now correctly handles combinations and single filters. // Assumes monthNum and yearNum are valid integers if present. const expenseMonth = date.getMonth() + 1; // 1-12 const expenseYear = date.getFullYear(); if (filters.month) { const filterMonth = parseInt(filters.month); if (expenseMonth !== filterMonth) return false; } if (filters.year) { const filterYear = parseInt(filters.year); if (expenseYear !== filterYear) return false; } // 5. Filter by week number of the month // This logic only runs IF filters.week is present. // It assumes filters.month is also present and valid because of prior validation in command action. if (filters.week) { const weekNum = parseInt(filters.week); // Guaranteed to be valid (1-5) by now // At this point, if filters.month and filters.year (if present) were used, // the expense has ALREADY passed those checks. // So, we just need to compare the expense's week of month. const weekOfMonth = getWeekOfMonth(date); if (weekOfMonth !== weekNum) return false; } // If the expense passes all applied filters, keep it return true; }); } // --- NEW: Function for common filter validation --- function validateFilterOptions(options) { // 1. Check for mutually exclusive filters (date vs. other time filters) const hasAnyTimeFilter = options.day || options.month || options.week || options.year; if (options.date) { if (hasAnyTimeFilter) { console.error( chalk.red( `āŒ Error: Cannot use --date with --day, --month, --week, or --year. Please choose one filtering method.`, ), ); process.exit(1); } // Validate --date format (YYYY-MM-DD) const parsedDate = new Date(options.date); if ( !/^\d{4}-\d{2}-\d{2}$/.test(options.date) || isNaN(parsedDate.getTime()) ) { console.error( chalk.red( `āŒ Error: Invalid --date format. Please use YYYY-MM-DD (e.g., 2025-07-29).`, ), ); process.exit(1); } } // 2. Validate --week combination and range if (options.week) { const weekNum = parseInt(options.week); if (isNaN(weekNum) || weekNum < 1 || weekNum > 5) { console.error( chalk.red( `āŒ Error: Invalid --week value '${options.week}'. Must be between 1 and 5 for week of month.`, ), ); process.exit(1); } if (!options.month) { console.error( chalk.red( `āŒ Error: The --week option must be used with --month (and optionally --year) for meaningful filtering.`, ), ); process.exit(1); } } // 3. Validate --month range if (options.month) { const monthNum = parseInt(options.month); if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) { console.error( chalk.red(`āŒ Error: Invalid --month value. Must be between 1 and 12.`), ); process.exit(1); } } // 4. Validate --year format if (options.year) { const yearNum = parseInt(options.year); if (isNaN(yearNum) || String(yearNum).length !== 4) { console.error( chalk.red( `āŒ Error: Invalid --year value. Please use a 4-digit year (e.g., 2025).`, ), ); process.exit(1); } } // 5. Validate --day name if (options.day) { const validDays = [ "monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday", ]; if (!validDays.includes(options.day.toLowerCase())) { console.error( chalk.red( `āŒ Error: Invalid --day value. Must be one of: ${validDays .map((d) => d.charAt(0).toUpperCase() + d.slice(1)) .join(", ")}.`, ), ); process.exit(1); } } } // --- END NEW: Function for common filter validation --- // --- New: Global loading indicator and timer --- (remains unchanged) let loadingInterval; let startTime; let loadingMessagePrefix = "Please wait"; // Default prefix function startLoadingMessage(prefix = "Please wait") { startTime = process.hrtime.bigint(); // High-resolution time loadingMessagePrefix = prefix; let dots = 0; // Clear any existing loading message remnants before starting a new one readline.cursorTo(process.stdout, 0); readline.clearLine(process.stdout, 1); process.stdout.write(chalk.gray(`${loadingMessagePrefix}...`)); loadingInterval = setInterval(() => { dots = (dots + 1) % 4; readline.cursorTo(process.stdout, 0); process.stdout.write( chalk.gray(`${loadingMessagePrefix}${".".repeat(dots)}`), ); }, 300); } function stopLoadingMessage() { if (loadingInterval) { clearInterval(loadingInterval); const endTime = process.hrtime.bigint(); const durationMs = Number(endTime - startTime) / 1_000_000; // Convert nanoseconds to milliseconds readline.cursorTo(process.stdout, 0); // Move cursor to the beginning of the line readline.clearLine(process.stdout, 1); // Clear the current line if (durationMs >= 100) { // Only show duration if it's more than 100ms console.log( chalk.gray(`Operation completed in ${durationMs.toFixed(2)} ms.`), ); } loadingInterval = null; // Reset interval ID } } // --- Global helper for opening URLs in browser --- (remains unchanged) const openUrlInBrowser = (url) => { const platform = process.platform; let command; if (platform === "win32") { command = `start "" "${url}"`; } else if (platform === "darwin") { command = `open "${url}"`; } else if (platform === "linux") { command = `xdg-open "${url}"`; } else { console.warn( chalk.yellow("Automatic browser opening not supported on this platform."), ); return; } try { execSync(command, { stdio: "ignore" }); console.log(chalk.gray(`(Opened ${url} in your default browser)`)); } catch (err) { console.error(chalk.red(`Failed to open URL: ${url}`), err); } }; // --- End of openUrlInBrowser definition --- // --- GLOBAL UTILITY: Moved wrapTextForPdf here to be accessible by multiple commands --- function wrapTextForPdf(text, maxWidth, font, fontSize) { const explicitLines = text.split("\n"); let finalLines = []; explicitLines.forEach((linePart) => { const words = linePart.split(" "); let currentLine = ""; if (linePart.trim() === "") { finalLines.push(""); return; } words.forEach((word) => { const testLine = currentLine ? `${currentLine} ${word}` : word; const width = font.widthOfTextAtSize(testLine, fontSize); if (width > maxWidth) { if (currentLine) { finalLines.push(currentLine); } currentLine = word; } else { currentLine = testLine; } }); if (currentLine) { finalLines.push(currentLine); } }); return finalLines; } // --- END GLOBAL UTILITY --- // --- NEW: Undo/Redo Stack Management Functions --- // Store states with a 'command' property for better user feedback function readStack(stackFile) { try { const data = fs.readFileSync(stackFile, "utf-8"); return JSON.parse(data); } catch { // If file is empty or corrupt, return an empty array return []; } } function writeStack(stackFile, stack) { fs.writeFileSync(stackFile, JSON.stringify(stack, null, 2), "utf-8"); } function pushToUndoStack(commandName) { const undoStack = readStack(undoStackFile); const currentData = fs.readFileSync(dataFile, "utf-8"); const currentConfig = fs.readFileSync(configFile, "utf-8"); undoStack.push({ command: commandName, // Store the name of the command being undone data: JSON.parse(currentData), config: JSON.parse(currentConfig), }); const MAX_UNDO_STATES = 5; // Keep the last 5 states if (undoStack.length > MAX_UNDO_STATES) { undoStack.shift(); // Remove the oldest state } writeStack(undoStackFile, undoStack); } function popFromUndoStack() { const undoStack = readStack(undoStackFile); if (undoStack.length > 0) { const prevState = undoStack.pop(); writeStack(undoStackFile, undoStack); // Save the undo stack after popping // Push the current (before undo) state to redo stack pushToRedoStack(prevState.command); // Use the command name from the undone state // Revert data.json and config.json fs.writeFileSync( dataFile, JSON.stringify(prevState.data, null, 2), "utf-8", ); fs.writeFileSync( configFile, JSON.stringify(prevState.config, null, 2), "utf-8", ); return prevState.command; // Return the name of the undone command } return null; // Nothing to undo } function pushToRedoStack(commandName) { const redoStack = readStack(redoStackFile); const currentData = fs.readFileSync(dataFile, "utf-8"); const currentConfig = fs.readFileSync(configFile, "utf-8"); redoStack.push({ command: commandName, // Store the name of the command being redone data: JSON.parse(currentData), config: JSON.parse(currentConfig), }); const MAX_REDO_STATES = 5; // Keep the last 5 redo states if (redoStack.length > MAX_REDO_STATES) { redoStack.shift(); // Remove the oldest state } writeStack(redoStackFile, redoStack); } function popFromRedoStack() { const redoStack = readStack(redoStackFile); if (redoStack.length > 0) { const nextState = redoStack.pop(); writeStack(redoStackFile, redoStack); // Save the redo stack after popping // Push the current (before redo) state to undo stack // This allows undoing the redo itself pushToUndoStack(nextState.command); // Revert data.json and config.json fs.writeFileSync( dataFile, JSON.stringify(nextState.data, null, 2), "utf-8", ); fs.writeFileSync( configFile, JSON.stringify(nextState.config, null, 2), "utf-8", ); return nextState.command; // Return the name of the redone command } return null; // Nothing to redo } function clearRedoStack() { writeStack(redoStackFile, []); // Empty the redo stack } // --- END NEW: Undo/Redo Stack Management Functions --- // *** NEW FEATURE: Function to handle initial currency setup on first run *** async function promptForInitialCurrency() { const config = getConfig(); // This check is slightly redundant if called from main(), but good for standalone robustness if (config.preferredCurrency !== null) { return true; } stopLoadingMessage(); // Ensure no spinners are running console.log(chalk.cyan("\nšŸ‘‹ Welcome to expenses-tracker-cli!")); console.log(chalk.blue("To get started, let's set your default currency.")); console.log( chalk.gray( "This will be used for all future expenses unless you specify a different one.", ), ); console.log( chalk.gray( "You can change **this default currency** at any time with 'expense change-currency.\n", ), ); // Prompt for the initial currency code const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const initialInput = await new Promise((resolve) => { rl.question( chalk.yellow( "Please enter your preferred 3-letter currency code (e.g., USD, EUR, BDT): ", ), (answer) => { rl.close(); resolve(answer.trim()); }, ); }); if (!initialInput) { console.log( chalk.yellow( "\nSetup cancelled. You can set the currency later using the 'change-currency' command.", ), ); return false; } // Use the existing robust validation function const chosenCurrency = await validateAndSuggestCurrency(initialInput); if (chosenCurrency) { config.preferredCurrency = chosenCurrency; saveConfig(config); console.log( chalk.green( `\nāœ… Your preferred currency has been set to ${chosenCurrency}.`, ), ); return true; } else { // validateAndSuggestCurrency handles its own error messages for failure console.log( chalk.yellow( "\nSetup cancelled. You can set the currency later using the 'change-currency' command.", ), ); return false; } } // --- Commander.js Command Definitions --- program .name("expense") // <--- Updated name .description( "A simple command-line expense tracker. \nYou can also filter expenses based on week, month, year, or specific date when typing list,total or export subcommand. \n\nFor a comprehensive PDF manual, type: expense manual [--open]", ) .version("1.0.0", "-v, --version", "Show version number"); // Global version option // === THE KEY CHANGE: Disable Commander's automatic help option for all commands === program.helpOption(false); // This stops Commander from adding -h, --help to all commands. program.addHelpCommand(false); // ADDED: Prevents Commander from creating the 'help' command entry // Manually define a global help option action. // When 'expense --help' or 'expense -h' is encountered, this action is triggered. program.option("-h, --help", "display help for command", () => { // Check if there's a subcommand specified (e.g., 'expense list --help') // and that the subcommand isn't also a global help/version flag. const subcommandName = process.argv[2]; // The argument right after 'expense' // This block specifically handles 'expense --help' or 'expense -h' // and 'expense <valid-subcommand> --help' if ( subcommandName && !["--help", "-h", "--version", "-v"].includes(subcommandName) ) { const command = program.commands.find( (c) => c.name() === subcommandName || c.aliases().includes(subcommandName), ); if (command) { command.outputHelp(); // Show help for that specific subcommand process.exit(0); } } // Otherwise, show global help (e.g., if no subcommand or if the subcommand was -h/-v) program.outputHelp(); process.exit(0); }); // The rest of your command definitions (add, change-currency, edit, reset, delete, recover, list, total, export) // remain exactly the same as they were. No changes needed to their internal logic for pre-parsing interception // because the interception happens at the top level before Commander even tries to match. program .command("add <amount> <description...>") .alias("a") .description( "Add a new expense. E.g., Use '--currency EUR' for a specific currency on this expense.", ) .option( "-c, --currency <code>", "Currency code, e.g. USD, EUR (defaults to preferred currency)", ) .action(async (amount, description, options) => { startLoadingMessage("Adding expense"); try { pushToUndoStack("add"); // Save current state before modification clearRedoStack(); // Clear redo stack on new command const parsedAmount = parseFloat(amount); if (isNaN(parsedAmount) || parsedAmount <= 0) { console.error( chalk.red("āŒ Invalid amount. Please enter a positive number."), ); return; } const config = getConfig(); let transactionCurrency = config.preferredCurrency || "USD"; if (options.currency) { // validateAndSuggestCurrency internally stops the spinner before prompt const validatedOptionCurrency = await validateAndSuggestCurrency( options.currency, ); // If validation needed a prompt, the spinner was stopped. // It should NOT be restarted here just to be stopped again immediately. if (validatedOptionCurrency) { transactionCurrency = validatedOptionCurrency; } else { console.error( chalk.red( "āŒ Invalid currency code provided for this expense. Using preferred/default currency.", ), ); } } const expenses = readExpenses(); const expense = { id: expenses.length ? Math.max(...expenses.map((exp) => exp.id)) + 1 : 1, amount: parsedAmount, description: description.join(" "), date: new Date().toISOString(), currency: transactionCurrency, originalAmount: parsedAmount, originalCurrency: transactionCurrency, isDeleted: false, deletedAt: null, }; expenses.push(expense); writeExpenses(expenses); console.log( chalk.green( `āœ… Added expense #${expense.id}: ${expense.amount.toFixed( 2, )} ${transactionCurrency} - ${expense.description}`, ), ); } finally { stopLoadingMessage(); } }); program .command("change-currency") .description( "Change the preferred currency for future transactions and optionally convert past ones. E.g., Use '--currency USD' to set USD.", ) .option( "-c, --currency <code>", "New currency code (e.g., USD, EUR, BDT)", true, ) // `true` makes it required .action(async (options) => { // --- START: THE FIX --- // This check now correctly handles the case where the --currency flag is missing entirely. if (typeof options.currency !== "string") { console.error( chalk.red( "āŒ Error: The --currency flag is required for this command.", ), ); console.log( chalk.blue("\nExample: expense change-currency --currency USD"), ); return; // Exit the function gracefully } // --- END: THE FIX --- startLoadingMessage("Changing currency"); try { pushToUndoStack("change-currency"); clearRedoStack(); const newCurrency = await validateAndSuggestCurrency(options.currency); if (!newCurrency) { return; // Exit if currency validation failed and no valid currency was chosen } const config = getConfig(); const oldPreferredCurrency = config.preferredCurrency; if (oldPreferredCurrency !== newCurrency) { let confirmedConversion = false; let exchangeRate = null; if (oldPreferredCurrency !== null) { stopLoadingMessage(); confirmedConversion = await promptConfirmation( chalk.blue( `Do you want to convert past expenses from ${oldPreferredCurrency} to ${newCurrency}?`, ), ); if (confirmedConversion) { // --- START: Exchange Rate Input Loop with Limit --- let rateAttempts = 0; const maxRateAttempts = 3; // Define the limit for exchange rate input let isValidRate = false; while (!isValidRate && rateAttempts < maxRateAttempts) { stopLoadingMessage(); console.log( chalk.blue( `\nTo find live exchange rates for ${oldPreferredCurrency} to ${newCurrency}, you can visit one of these reliable sites:`, ), ); console.log( ` ${chalk.underline.bold( "1. XE.com: https://www.xe.com/currencyconverter/", )}`, ); console.log( ` ${chalk.underline.bold( "2. Wise: https://wise.com/currency-converter/", )}`, ); console.log( ` ${chalk.underline.bold( "3. OANDA: https://www.oanda.com/currency-converter/en/", )}\n`, ); const openLinkConfirmation = await promptConfirmation( chalk.blue( "Would you like to open one of these links in your browser now?", ), ); if (openLinkConfirmation) { let linkChoice = ""; while (!["1", "2", "3"].includes(linkChoice)) { const rlChoice = readline.createInterface({ input: process.stdin, output: process.stdout, }); linkChoice = await new Promise((resolve) => { rlChoice.question( chalk.cyan( "Enter the number of the link you want to open (1, 2, or 3): ", ), (answer) => { rlChoice.close(); resolve(answer.trim()); }, ); }); if (!["1", "2", "3"].includes(linkChoice)) { console.log( chalk.yellow("Invalid choice. Please enter 1, 2, or 3."), ); } } let urlToOpen; if (linkChoice === "1") urlToOpen = "https://www.xe.com/currencyconverter/"; else if (linkChoice === "2") urlToOpen = "https://wise.com/currency-converter/"; else if (linkChoice === "3") urlToOpen = "https://www.oanda.com/currency-converter/en/"; if (urlToOpen) { openUrlInBrowser(urlToOpen); } } const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); const rateInput = await new Promise((resolve) => { rl.question( chalk.cyan( `Enter the exchange rate (1 ${oldPreferredCurrency} = X ${newCurrency}) (Attempt ${ rateAttempts + 1 }/${maxRateAttempts}): `, ), // Added attempt counter to prompt (answer) => { rl.close(); resolve(answer.trim()); }, ); }); const parsedRate = parseFloat(rateInput); if (!isNaN(parsedRate) && parsedRate > 0) { exchangeRate = parsedRate; isValidRate = true; } else { console.error( chalk.red( "āŒ Invalid exchange rate. Please enter a positive number.", ), ); } rateAttempts++; } // --- END: Exchange Rate Input Loop with Limit --- if (!isValidRate) { console.error( chalk.red( `\nExceeded maximum attempts (${maxRateAttempts}) for exchange rate. Conversion of past expenses aborted.`, ), ); // Optionally, you could exit here, but allowing the currency change without conversion // might be a softer user experience. Let's proceed without conversion. confirmedConversion = false; // Ensure conversion doesn't happen exchangeRate = null; // Reset exchange rate } } } await changeCurrency(newCurrency, confirmedConversion, exchangeRate); } else { console.log( chalk.yellow( `ā„¹ļø Preferred currency is already set to ${newCurrency}. No change needed.`, ), ); } } finally { stopLoadingMessage(); } }); program .command("edit <id>") .description("Modify an existing expense's details.") .option( "-d, --description <description...>", "New description for the expense", ) .option("-a, --amount <amount>", "New amount for the expense (number)") .option( "-c, --currency <code>", "New currency for the expense (e.g., USD, EUR)", ) .option("--date <YYYY-MM-DD>", "New date for the expense (e.g., 2024-07-28)") .action(async (id, options) => { startLoadingMessage(`Preparing to edit expense #${id}`); try { pushToUndoStack("edit"); // Save current state before modification clearRedoStack(); // Clear redo stack on new command const expenseId = parseInt(id); if (isNaN(expenseId) || expenseId <= 0) { // Changed parsedAmount to expenseId here console.error( chalk.red("āŒ Invalid expense ID. Please enter a positive number."), ); return; } let expenses = readExpenses(); const expenseToEdit = expenses.find((exp) => exp.id === expenseId); if (!expenseToEdit) { console.log( chalk.yellow(`ā„¹ļø Expense with ID #${expenseId} not found.`), ); return; } // --- START OF NEW FEEDBACK LOGIC --- const noOptionsProvided = !( options.description || options.amount || options.currency || options.date ); if (noOptionsProvided) { stopLoadingMessage(); // Stop the spinner before printing messages console.log( chalk.yellow( `\nā„¹ļø To edit expense #${expenseId}, please provide at least one option:`, ), ); console.log(` - ${chalk.bold("--amount <number>")} (e.g., 75.50)`); console.log( ` - ${chalk.bold( "--description <text...>", )} (e.g., "Updated item for lunch")`, ); console.log(` - ${chalk.bold("--currency <CODE>")} (e.g., GBP)`); console.log( ` - ${chalk.bold("--date <YYYY-MM-DD>")} (e.g., 2025-07-30)`, ); console.log( chalk.yellow( `\n Example: ${chalk.bold( `expense edit ${expenseId} --amount 120 --description "Lunch with team"`, )}`, ), ); return; // Exit the action as no edit options were provided } // --- END OF NEW FEEDBACK LOGIC --- let changesMade = false; let oldDetails = { description: expenseToEdit.description, amount: expenseToEdit.amount, currency: expenseToEdit.currency, date: expenseToEdit.date, }; let newDetails = { description: expenseToEdit.description, amount: expenseToEdit.amount, currency: expenseToEdit.currency, date: expenseToEdit.date, }; if (options.description) { const newDescription = options.description.join(" "); if (expenseToEdit.description !== newDescription) { expenseToEdit.description = newDescription; newDetails.description = newDescription; changesMade = true; } } if (options.amount) { const newAmount = parseFloat(options.amount); if (isNaN(newAmount) || newAmount <= 0) { console.error( chalk.red( "āŒ Invalid amount provided. Amount must be a positive number.", ), ); } else if (Math.abs(expenseToEdit.amount - newAmount) > 0.001) { expenseToEdit.amount = newAmount; newDetails.amount = newAmount; changesMade = true; } } if (options.currency) { const validatedCurrency = await validateAndSuggestCurrency( options.currency, ); if (validatedCurrency) { if (expenseToEdit.currency !== validatedCurrency) { expenseToEdit.currency = validatedCurrency; newDetails.currency = validatedCurrency; expenseToEdit.originalCurrency = validatedCurrency; changesMade = true; } } else { console.error(chalk.red("āŒ Invalid currency code provided.")); } } if (options.date) { const parsedDate = new Date(options.date); if (isNaN(parsedDate.getTime())) { console.error( chalk.red("āŒ Invalid date format. Please use YYYY-MM-DD."), ); } else { const oldDate = new Date(expenseToEdit.date); if (!isSameDate(oldDate, parsedDate)) { expenseToEdit.date = parsedDate.toISOString(); newDetails.date = parsedDate.toISOString(); changesMade = true; } } } if (!changesMade) { console.log(chalk.yellow("ā„¹ļø No changes detected or applied.")); return; } stopLoadingMessage(); let confirmationMessage = chalk.blue( `ā“ Are you sure you want to apply these changes to expense #${expenseId}?\n`, ); confirmationM