Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-analytics-sheets-respect-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mpesa2csv": patch
---

Fix analytics sheets ignoring active export filters (e.g. exclude charges, sort order).
5 changes: 5 additions & 0 deletions .changeset/fix-multi-file-password-state-loss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mpesa2csv": patch
---

Fix multi-file batch losing already-processed statements when a mid-batch file requires a password.
5 changes: 5 additions & 0 deletions .changeset/fix-silent-empty-success.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mpesa2csv": patch
---

Treat a parsed PDF with zero transactions as an error instead of showing a silent success screen.
5 changes: 5 additions & 0 deletions .changeset/fix-timeout-kills-java.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mpesa2csv": patch
---

Kill the Java/Tabula process when PDF extraction times out instead of leaving it running in the background.
50 changes: 47 additions & 3 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<Option<u32>>);

#[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;
Expand All @@ -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<String>,
Expand Down Expand Up @@ -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\
Expand All @@ -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))
Expand Down Expand Up @@ -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");
}
60 changes: 42 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -39,6 +39,9 @@ function App() {
includeTopContactsSheet: false,
});
const [currentFileIndex, setCurrentFileIndex] = useState<number>(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<MPesaStatement[]>([]);
const [isDownloading, setIsDownloading] = useState<boolean>(false);
const [appVersion, setAppVersion] = useState<string>("");
const [downloadSuccess, setDownloadSuccess] = useState<boolean>(false);
Expand Down Expand Up @@ -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);
}
Expand All @@ -135,24 +140,36 @@ function App() {
try {
setStatus(FileStatus.PROCESSING);

let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const extractionPromise = TabulaService.extractTablesFromPdf(file);
const timeoutPromise = new Promise<string>((_, 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<string>((_, 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) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -320,6 +343,7 @@ function App() {
setError(undefined);
setStatements([]);
setCurrentFileIndex(0);
pendingStatements.current = [];
setIsDownloading(false);
setDownloadSuccess(false);
setSavedFilePath("");
Expand Down
33 changes: 14 additions & 19 deletions src/services/xlsxService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading