diff --git a/.changeset/fix-analytics-sheets-respect-filters.md b/.changeset/fix-analytics-sheets-respect-filters.md new file mode 100644 index 0000000..af02dff --- /dev/null +++ b/.changeset/fix-analytics-sheets-respect-filters.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Fix analytics sheets ignoring active export filters (e.g. exclude charges, sort order). diff --git a/.changeset/fix-multi-file-password-state-loss.md b/.changeset/fix-multi-file-password-state-loss.md new file mode 100644 index 0000000..07621c7 --- /dev/null +++ b/.changeset/fix-multi-file-password-state-loss.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Fix multi-file batch losing already-processed statements when a mid-batch file requires a password. diff --git a/.changeset/fix-silent-empty-success.md b/.changeset/fix-silent-empty-success.md new file mode 100644 index 0000000..b698ca0 --- /dev/null +++ b/.changeset/fix-silent-empty-success.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Treat a parsed PDF with zero transactions as an error instead of showing a silent success screen. diff --git a/.changeset/fix-timeout-kills-java.md b/.changeset/fix-timeout-kills-java.md new file mode 100644 index 0000000..d5c5a16 --- /dev/null +++ b/.changeset/fix-timeout-kills-java.md @@ -0,0 +1,5 @@ +--- +"mpesa2csv": patch +--- + +Kill the Java/Tabula process when PDF extraction times out instead of leaving it running in the background. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2801eab..7396032 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,33 @@ +/// Shared state that holds the PID of the currently running Java/Tabula process +/// so it can be killed if the frontend times out. +pub struct ActivePid(pub std::sync::Mutex>); + #[tauri::command] fn greet(name: &str) -> String { format!("Hello, {}! You've been greeted from Rust!", name) } +#[tauri::command] +fn cancel_pdf_extraction(active_pid: tauri::State<'_, ActivePid>) { + let pid = active_pid.0.lock().unwrap().take(); + if let Some(pid) = pid { + #[cfg(unix)] + { + std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output() + .ok(); + } + #[cfg(windows)] + { + std::process::Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .output() + .ok(); + } + } +} + #[cfg(target_os = "windows")] fn normalize_windows_path(path: std::path::PathBuf) -> std::path::PathBuf { use std::path::PathBuf; @@ -24,6 +49,7 @@ fn normalize_windows_path(path: std::path::PathBuf) -> std::path::PathBuf { #[tauri::command] async fn extract_pdf_tables( app_handle: tauri::AppHandle, + active_pid: tauri::State<'_, ActivePid>, pdf_path: String, output_path: String, password: Option, @@ -136,8 +162,8 @@ async fn extract_pdf_tables( cmd.arg("--password").arg(pwd); } - let output = cmd - .output() + let child = cmd + .spawn() .map_err(|e| { format!( "Failed to execute Java (using {}): {}\n\ @@ -157,6 +183,23 @@ async fn extract_pdf_tables( e ) })?; + + // Store the PID so cancel_pdf_extraction can kill this process if the + // frontend times out. + let pid = child.id(); + *active_pid.0.lock().unwrap() = Some(pid); + + let output = child + .wait_with_output() + .map_err(|e| format!("Failed waiting for Java process: {}", e))?; + + // Clear the PID — process has finished. + { + let mut guard = active_pid.0.lock().unwrap(); + if *guard == Some(pid) { + *guard = None; + } + } if output.status.success() { Ok(format!("Tables extracted successfully to: {}", output_path)) @@ -332,12 +375,13 @@ async fn save_file( #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(ActivePid(std::sync::Mutex::new(None))) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) - .invoke_handler(tauri::generate_handler![greet, extract_pdf_tables, save_csv_file, save_file, get_app_version, open_file]) + .invoke_handler(tauri::generate_handler![greet, extract_pdf_tables, cancel_pdf_extraction, save_csv_file, save_file, get_app_version, open_file]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 508f23e..15f311f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { invoke } from "@tauri-apps/api/core"; import { platform } from "@tauri-apps/plugin-os"; import { Download, RotateCcw, ExternalLink, MessageSquare } from "lucide-react"; @@ -39,6 +39,9 @@ function App() { includeTopContactsSheet: false, }); const [currentFileIndex, setCurrentFileIndex] = useState(0); + // Holds statements processed before a mid-batch password prompt so they + // are not lost when handlePasswordSubmit runs (statements state is still []). + const pendingStatements = useRef([]); const [isDownloading, setIsDownloading] = useState(false); const [appVersion, setAppVersion] = useState(""); const [downloadSuccess, setDownloadSuccess] = useState(false); @@ -109,7 +112,9 @@ function App() { try { const result = await processFiles(selectedFiles); - if (result?.error) { + if (result?.needsPassword) { + pendingStatements.current = result.processedStatements; + } else if (result?.error) { setStatus(FileStatus.ERROR); setError(result.error); } @@ -135,24 +140,36 @@ function App() { try { setStatus(FileStatus.PROCESSING); + let timeoutHandle: ReturnType | undefined; + const extractionPromise = TabulaService.extractTablesFromPdf(file); + const timeoutPromise = new Promise((_, reject) => { + timeoutHandle = setTimeout(async () => { + try { + await invoke("cancel_pdf_extraction"); + } catch { + // best-effort cancel — ignore errors + } + reject( + new Error( + `PDF processing timeout after ${ + TIMEOUTS.PDF_PROCESSING / 1000 + } seconds` + ) + ); + }, TIMEOUTS.PDF_PROCESSING); + }); + const csvContent = await Promise.race([ - TabulaService.extractTablesFromPdf(file), - new Promise((_, reject) => - setTimeout( - () => - reject( - new Error( - `PDF processing timeout after ${ - TIMEOUTS.PDF_PROCESSING / 1000 - } seconds` - ) - ), - TIMEOUTS.PDF_PROCESSING - ) - ), + extractionPromise.finally(() => clearTimeout(timeoutHandle)), + timeoutPromise, ]); const statement = TabulaService.parseTabulaCSV(csvContent); + if (statement.transactions.length === 0) { + throw new Error( + `No transactions found in "${file.name}". The file may not be a valid M-PESA statement.` + ); + } statement.fileName = file.name; processedStatements.push(statement); } catch (err: any) { @@ -242,13 +259,19 @@ function App() { const statement = TabulaService.parseTabulaCSV(csvContent); statement.fileName = currentFile.name; - const updatedStatements = [...statements, statement]; + // Use the ref so already-processed files are not lost (statements state + // is still [] when processFiles returned early for a password prompt). + const updatedStatements = [...pendingStatements.current, statement]; + pendingStatements.current = []; setStatements(updatedStatements); const nextIndex = currentFileIndex + 1; if (nextIndex < files.length) { const result = await processFiles(files, nextIndex, updatedStatements); - if (result?.error) { + if (result?.needsPassword) { + // Another file in the batch needs a password; persist current progress. + pendingStatements.current = result.processedStatements; + } else if (result?.error) { setStatus(FileStatus.ERROR); setError(result.error); } @@ -320,6 +343,7 @@ function App() { setError(undefined); setStatements([]); setCurrentFileIndex(0); + pendingStatements.current = []; setIsDownloading(false); setDownloadSuccess(false); setSavedFilePath(""); diff --git a/src/services/xlsxService.ts b/src/services/xlsxService.ts index 95b84bc..62063b2 100644 --- a/src/services/xlsxService.ts +++ b/src/services/xlsxService.ts @@ -112,54 +112,49 @@ export class XlsxService { }); } - // Add Charges/Fees sheet if requested + // Add Charges/Fees sheet if requested. + // Always uses the unfiltered statement so the sheet has data even when + // "filter out charges" is active on the main sheet. if (options?.includeChargesSheet) { addChargesSheet(workbook, statement); } - // Add Financial Summary sheet if requested + // All remaining analytics sheets use filteredStatement so active filters + // (e.g. exclude charges, sort order) are consistently reflected. if (options?.includeSummarySheet) { - addFinancialSummarySheet(workbook, statement); + addFinancialSummarySheet(workbook, filteredStatement); } - // Add Monthly/Weekly Breakdown sheet if requested if (options?.includeBreakdownSheet) { - addMonthlyWeeklyBreakdownSheet(workbook, statement); + addMonthlyWeeklyBreakdownSheet(workbook, filteredStatement); } - // Add Daily Balance Tracker sheet if requested if (options?.includeDailyBalanceSheet) { - addDailyBalanceTrackerSheet(workbook, statement); + addDailyBalanceTrackerSheet(workbook, filteredStatement); } - // Add Transaction Amount Distribution sheet if requested if (options?.includeAmountDistributionSheet) { - addTransactionAmountDistributionSheet(workbook, statement); + addTransactionAmountDistributionSheet(workbook, filteredStatement); } - // Add Top Contacts sheet if requested if (options?.includeTopContactsSheet) { - addTopContactsSheet(workbook, statement); + addTopContactsSheet(workbook, filteredStatement); } - // Add Money In sheet if requested if (options?.includeMoneyInSheet) { - addMoneyInSheet(workbook, statement); + addMoneyInSheet(workbook, filteredStatement); } - // Add Money Out sheet if requested if (options?.includeMoneyOutSheet) { - addMoneyOutSheet(workbook, statement); + addMoneyOutSheet(workbook, filteredStatement); } - // Add Recurring Transactions sheet if requested if (options?.includeRecurringTransactionsSheet) { - addRecurringTransactionsSheet(workbook, statement); + addRecurringTransactionsSheet(workbook, filteredStatement); } - // Add Time-of-Day Activity sheet if requested if (options?.includeTimeOfDaySheet) { - addTimeOfDayActivitySheet(workbook, statement); + addTimeOfDayActivitySheet(workbook, filteredStatement); } const buffer = await workbook.xlsx.writeBuffer();