From 89b6afa9e52890dc730d20e4303ff1861837dcb1 Mon Sep 17 00:00:00 2001 From: Yunat Amos Date: Sun, 19 Oct 2025 16:21:09 +0300 Subject: [PATCH 1/5] feat: add password caching system with file hash tracking --- src/App.tsx | 65 +++++++++++++++++++- src/components/password-prompt.tsx | 22 ++++++- src/stores/passwordCacheStore.ts | 79 ++++++++++++++++++++++++ src/utils/fileHash.ts | 96 ++++++++++++++++++++++++++++++ 4 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 src/stores/passwordCacheStore.ts create mode 100644 src/utils/fileHash.ts diff --git a/src/App.tsx b/src/App.tsx index ef84373..ce82e36 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,9 +17,12 @@ import ExportOptions from "./components/export-options"; import { UpdateChecker } from "./components/update-checker"; import { Button } from "./components/ui/button"; import { formatDateForFilename } from "./utils/helpers"; +import { calculateQuickFileHash } from "./utils/fileHash"; +import { usePasswordCacheStore } from "./stores/passwordCacheStore"; function App() { const [files, setFiles] = useState([]); + const [fileHashes, setFileHashes] = useState>(new Map()); const [status, setStatus] = useState(FileStatus.IDLE); const [error, setError] = useState(undefined); const [statements, setStatements] = useState([]); @@ -40,6 +43,9 @@ function App() { const [downloadSuccess, setDownloadSuccess] = useState(false); const [savedFilePath, setSavedFilePath] = useState(""); const [currentPlatform, setCurrentPlatform] = useState(""); + const [cachedPassword, setCachedPassword] = useState(null); + + const { getPassword, savePassword } = usePasswordCacheStore(); const getDefaultFileName = () => { return ( @@ -103,9 +109,30 @@ function App() { setError(undefined); setStatements([]); setCurrentFileIndex(0); + + // Calculate hashes for all files + console.log('[App] Starting file hash calculation for', selectedFiles.length, 'files'); + const hashMap = new Map(); + try { + await Promise.all( + selectedFiles.map(async (file, index) => { + try { + const hash = await calculateQuickFileHash(file); + hashMap.set(index, hash); + console.log('[App] Hash calculated for file', index, ':', file.name, '->', hash.substring(0, 16) + '...'); + } catch (err) { + console.error(`[App] Failed to hash file ${file.name}:`, err); + } + }) + ); + setFileHashes(hashMap); + console.log('[App] All hashes calculated. Total:', hashMap.size); + } catch (err) { + console.error('[App] Error calculating file hashes:', err); + } try { - const result = await processFiles(selectedFiles); + const result = await processFiles(selectedFiles, 0, [], hashMap); if (result?.error) { setStatus(FileStatus.ERROR); setError(result.error); @@ -121,7 +148,8 @@ function App() { const processFiles = async ( filesToProcess: File[], startIndex: number = 0, - existingStatements: MPesaStatement[] = [] + existingStatements: MPesaStatement[] = [], + hashMap?: Map ) => { const processedStatements: MPesaStatement[] = [...existingStatements]; @@ -130,6 +158,12 @@ function App() { const file = filesToProcess[i]; try { + // Get cached password if available + const fileHash = (hashMap || fileHashes).get(i); + console.log('[App] Processing file', i, '- Hash:', fileHash ? fileHash.substring(0, 16) + '...' : 'NO HASH'); + const foundCachedPassword = fileHash ? getPassword(fileHash) : null; + console.log('[App] Cached password for file', i, ':', foundCachedPassword ? '***FOUND***' : 'NOT FOUND'); + const result = (await Promise.race([ PdfService.loadPdf(file), new Promise((_, reject) => @@ -141,10 +175,17 @@ function App() { ])) as { isProtected: boolean; pdf?: any }; if (result.isProtected) { + // Set cached password so the PasswordPrompt can auto-fill + if (foundCachedPassword) { + console.log('[App] ✅ Setting cached password for auto-fill'); + setCachedPassword(foundCachedPassword); + } setStatus(FileStatus.PROTECTED); return { needsPassword: true, fileIndex: i, processedStatements }; } else if (result.pdf) { setStatus(FileStatus.PROCESSING); + console.log('[App] ℹ️ No password needed for:', file.name); + const statement = (await Promise.race([ PdfService.parseMpesaStatement(result.pdf), new Promise((_, reject) => @@ -224,11 +265,27 @@ function App() { setStatus(FileStatus.PROCESSING); setError(undefined); + setCachedPassword(null); // Clear cached password after use try { const currentFile = files[currentFileIndex]; const pdf = await PdfService.unlockPdf(currentFile, password); + // Save password to cache if file was successfully unlocked + const fileHash = fileHashes.get(currentFileIndex); + console.log('[App] Attempting to save password for file', currentFileIndex); + console.log('[App] File hash:', fileHash ? fileHash.substring(0, 16) + '...' : 'NO HASH FOUND'); + console.log('[App] Current fileHashes map size:', fileHashes.size); + console.log('[App] All hashes in map:', Array.from(fileHashes.entries()).map(([k, v]) => `${k}: ${v.substring(0, 16)}...`)); + + if (fileHash) { + console.log('[App] ✅ Saving password to cache for:', currentFile.name); + savePassword(fileHash, password, currentFile.name); + console.log('[App] ✅ Password saved successfully'); + } else { + console.error('[App] ❌ Cannot save password - no hash found for file index:', currentFileIndex); + } + const statement = await PdfService.parseMpesaStatement(pdf); statement.fileName = currentFile.name; @@ -270,6 +327,7 @@ function App() { if (files.length === 0) return; setError(undefined); + setCachedPassword(null); // Clear cached password when skipping const nextIndex = currentFileIndex + 1; if (nextIndex < files.length) { @@ -306,6 +364,7 @@ function App() { const handleReset = () => { setFiles([]); + setFileHashes(new Map()); setStatus(FileStatus.IDLE); setError(undefined); setStatements([]); @@ -313,6 +372,7 @@ function App() { setIsDownloading(false); setDownloadSuccess(false); setSavedFilePath(""); + setCachedPassword(null); if (exportLink) { URL.revokeObjectURL(exportLink); @@ -454,6 +514,7 @@ function App() { currentFileName={files[currentFileIndex]?.name} currentFileIndex={currentFileIndex} totalFiles={files.length} + defaultPassword={cachedPassword} /> ) : status === FileStatus.PROCESSING ? ( diff --git a/src/components/password-prompt.tsx b/src/components/password-prompt.tsx index 1df272c..14d3391 100644 --- a/src/components/password-prompt.tsx +++ b/src/components/password-prompt.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { FileStatus } from "../types"; import { Lock } from "lucide-react"; import { cn } from "../lib/utils"; @@ -14,6 +14,7 @@ interface PasswordPromptProps { currentFileName?: string; currentFileIndex?: number; totalFiles?: number; + defaultPassword?: string | null; } const PasswordPrompt: React.FC = ({ @@ -25,8 +26,19 @@ const PasswordPrompt: React.FC = ({ currentFileName, currentFileIndex, totalFiles, + defaultPassword, }) => { const [password, setPassword] = useState(""); + const [isAutoFilled, setIsAutoFilled] = useState(false); + + // Auto-fill password when cached password is available + useEffect(() => { + if (defaultPassword) { + console.log('[PasswordPrompt] Auto-filling password from cache'); + setPassword(defaultPassword); + setIsAutoFilled(true); + } + }, [defaultPassword]); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); @@ -56,8 +68,14 @@ const PasswordPrompt: React.FC = ({ )}

- This PDF is password protected. Please enter the password to unlock it. + This PDF is password protected. {isAutoFilled ? 'Password auto-filled from cache.' : 'Please enter the password to unlock it.'}

+ + {isAutoFilled && ( +
+ ✅ Password remembered - click Unlock to continue +
+ )}
diff --git a/src/stores/passwordCacheStore.ts b/src/stores/passwordCacheStore.ts new file mode 100644 index 0000000..8af3de3 --- /dev/null +++ b/src/stores/passwordCacheStore.ts @@ -0,0 +1,79 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +interface PasswordCacheEntry { + fileHash: string; + password: string; + fileName: string; + timestamp: number; +} + +interface PasswordCacheState { + passwords: Record; + getPassword: (fileHash: string) => string | null; + savePassword: (fileHash: string, password: string, fileName: string) => void; + removePassword: (fileHash: string) => void; + clearAll: () => void; + getAll: () => PasswordCacheEntry[]; +} + +/** + * Store for caching PDF passwords based on file hash + * Persisted to localStorage + */ +export const usePasswordCacheStore = create()( + persist( + (set, get) => ({ + passwords: {}, + + getPassword: (fileHash: string) => { + const entry = get().passwords[fileHash]; + console.log('[PasswordCache] getPassword called:', { + fileHash: fileHash.substring(0, 16) + '...', + found: !!entry, + allHashes: Object.keys(get().passwords).map(h => h.substring(0, 16) + '...'), + }); + return entry ? entry.password : null; + }, + + savePassword: (fileHash: string, password: string, fileName: string) => { + console.log('[PasswordCache] savePassword called:', { + fileHash: fileHash.substring(0, 16) + '...', + fileName, + passwordLength: password.length, + }); + set((state) => ({ + passwords: { + ...state.passwords, + [fileHash]: { + fileHash, + password, + fileName, + timestamp: Date.now(), + }, + }, + })); + console.log('[PasswordCache] After save, total entries:', Object.keys(get().passwords).length); + }, + + removePassword: (fileHash: string) => { + set((state) => { + const { [fileHash]: _, ...rest } = state.passwords; + return { passwords: rest }; + }); + }, + + clearAll: () => { + set({ passwords: {} }); + }, + + getAll: () => { + return Object.values(get().passwords); + }, + }), + { + name: 'mpesa2csv-password-cache', + version: 1, + } + ) +); diff --git a/src/utils/fileHash.ts b/src/utils/fileHash.ts new file mode 100644 index 0000000..31f227b --- /dev/null +++ b/src/utils/fileHash.ts @@ -0,0 +1,96 @@ +/** + * Utility functions for file hashing + */ + +/** + * Calculates SHA-256 hash of a file + * @param file - The file to hash + * @returns Promise - Hex string representation of the hash + */ +export async function calculateFileHash(file: File): Promise { + try { + const arrayBuffer = await file.arrayBuffer(); + const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + return hashHex; + } catch (error) { + console.error('Error calculating file hash:', error); + throw new Error('Failed to calculate file hash'); + } +} + +/** + * Calculates a consistent hash based on file content only + * Uses file.slice() to avoid detaching the main buffer + * Hash is based on: file size + name + actual content chunks + * @param file - The file to hash + * @param chunkSize - Size of chunks to read (default 128KB) + * @returns Promise - Hex string representation of the hash + */ +export async function calculateQuickFileHash( + file: File, + chunkSize: number = 128 * 1024 +): Promise { + try { + console.log('[FileHash] Calculating hash for:', { + name: file.name, + size: file.size, + }); + + const fileSize = file.size; + const encoder = new TextEncoder(); + + // Create metadata WITHOUT lastModified to ensure consistency + const metadata = `${file.name}|${fileSize}`; + const metadataBytes = encoder.encode(metadata); + + let contentChunks: Uint8Array[] = []; + + if (fileSize === 0) { + // Empty file - just use metadata + contentChunks.push(metadataBytes); + } else if (fileSize <= chunkSize) { + // Small file - read entire content + console.log('[FileHash] Small file - reading entire content'); + const buffer = await file.slice(0, fileSize).arrayBuffer(); + contentChunks.push(new Uint8Array(buffer)); + contentChunks.push(metadataBytes); + } else { + // Large file - read first, middle, and last chunks for better uniqueness + console.log('[FileHash] Large file - reading first, middle, and last chunks'); + + const firstChunk = await file.slice(0, chunkSize).arrayBuffer(); + contentChunks.push(new Uint8Array(firstChunk)); + + // Read middle chunk + const middleStart = Math.floor((fileSize - chunkSize) / 2); + const middleChunk = await file.slice(middleStart, middleStart + chunkSize).arrayBuffer(); + contentChunks.push(new Uint8Array(middleChunk)); + + // Read last chunk + const lastChunk = await file.slice(fileSize - chunkSize, fileSize).arrayBuffer(); + contentChunks.push(new Uint8Array(lastChunk)); + + contentChunks.push(metadataBytes); + } + + // Combine all chunks + const totalLength = contentChunks.reduce((sum, chunk) => sum + chunk.length, 0); + const dataToHash = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of contentChunks) { + dataToHash.set(chunk, offset); + offset += chunk.length; + } + + const hashBuffer = await crypto.subtle.digest('SHA-256', dataToHash); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + console.log('[FileHash] Hash calculated:', hashHex.substring(0, 16) + '...', 'from', totalLength, 'bytes'); + return hashHex; + } catch (error) { + console.error('[FileHash] Error calculating file hash:', error); + throw new Error('Failed to calculate file hash'); + } +} From d5c54d3f4ff5857d23cc22f7370617c5046ac94d Mon Sep 17 00:00:00 2001 From: Yunat Amos Date: Sun, 19 Oct 2025 16:24:24 +0300 Subject: [PATCH 2/5] feat: implement max size limit and LRU eviction for password cache --- src/stores/passwordCacheStore.ts | 46 ++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/src/stores/passwordCacheStore.ts b/src/stores/passwordCacheStore.ts index 8af3de3..3f004e1 100644 --- a/src/stores/passwordCacheStore.ts +++ b/src/stores/passwordCacheStore.ts @@ -17,6 +17,8 @@ interface PasswordCacheState { getAll: () => PasswordCacheEntry[]; } +const MAX_CACHE_SIZE = 50; + /** * Store for caching PDF passwords based on file hash * Persisted to localStorage @@ -42,17 +44,39 @@ export const usePasswordCacheStore = create()( fileName, passwordLength: password.length, }); - set((state) => ({ - passwords: { - ...state.passwords, - [fileHash]: { - fileHash, - password, - fileName, - timestamp: Date.now(), - }, - }, - })); + + set((state) => { + const currentPasswords = { ...state.passwords }; + const currentSize = Object.keys(currentPasswords).length; + + // Add or update the password + currentPasswords[fileHash] = { + fileHash, + password, + fileName, + timestamp: Date.now(), + }; + + // If we exceeded the limit, remove oldest entries + if (currentSize >= MAX_CACHE_SIZE && !state.passwords[fileHash]) { + console.log('[PasswordCache] Cache limit reached, purging oldest entries'); + + // Sort entries by timestamp (oldest first) + const sortedEntries = Object.entries(currentPasswords) + .sort(([, a], [, b]) => a.timestamp - b.timestamp); + + // Remove oldest entries until we're under the limit + const entriesToRemove = sortedEntries.length - MAX_CACHE_SIZE + 1; + for (let i = 0; i < entriesToRemove; i++) { + const [hashToRemove] = sortedEntries[i]; + console.log('[PasswordCache] Removing old entry:', currentPasswords[hashToRemove].fileName); + delete currentPasswords[hashToRemove]; + } + } + + return { passwords: currentPasswords }; + }); + console.log('[PasswordCache] After save, total entries:', Object.keys(get().passwords).length); }, From 7b40b57e71c0d13aba5ece362b3ce83313a2292e Mon Sep 17 00:00:00 2001 From: Yunat Amos Date: Sun, 19 Oct 2025 16:50:53 +0300 Subject: [PATCH 3/5] feat: add recent files history modal with transaction tracking --- src/App.tsx | 134 ++++++++++++-- src/components/recent-files-history.tsx | 221 ++++++++++++++++++++++++ src/stores/recentFilesStore.ts | 102 +++++++++++ 3 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 src/components/recent-files-history.tsx create mode 100644 src/stores/recentFilesStore.ts diff --git a/src/App.tsx b/src/App.tsx index ce82e36..2f7456b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import { platform } from "@tauri-apps/plugin-os"; -import { Download, RotateCcw, ExternalLink } from "lucide-react"; +import { Download, RotateCcw, ExternalLink, History } from "lucide-react"; import { MPesaStatement, @@ -19,6 +19,8 @@ import { Button } from "./components/ui/button"; import { formatDateForFilename } from "./utils/helpers"; import { calculateQuickFileHash } from "./utils/fileHash"; import { usePasswordCacheStore } from "./stores/passwordCacheStore"; +import { useRecentFilesStore } from "./stores/recentFilesStore"; +import RecentFilesHistory from "./components/recent-files-history"; function App() { const [files, setFiles] = useState([]); @@ -44,8 +46,10 @@ function App() { const [savedFilePath, setSavedFilePath] = useState(""); const [currentPlatform, setCurrentPlatform] = useState(""); const [cachedPassword, setCachedPassword] = useState(null); + const [showHistory, setShowHistory] = useState(false); const { getPassword, savePassword } = usePasswordCacheStore(); + const { addFile: addRecentFile } = useRecentFilesStore(); const getDefaultFileName = () => { return ( @@ -104,6 +108,9 @@ function App() { }, []); const handleFilesSelected = async (selectedFiles: File[]) => { + console.log('[App] ===== FILE UPLOAD STARTED ====='); + console.log('[App] Selected files:', selectedFiles.map(f => f.name)); + setFiles(selectedFiles); setStatus(FileStatus.LOADING); setError(undefined); @@ -232,6 +239,10 @@ function App() { .catch(() => setExportLink("")); setExportFileName(fileName); setStatus(FileStatus.SUCCESS); + + // Save to recent files history + console.log('[App] Normal flow complete - saving to history'); + saveToRecentFiles(filesToProcess, combinedStatement, hashMap || fileHashes); } return { @@ -316,6 +327,10 @@ function App() { .catch(() => setExportLink("")); setExportFileName(fileName); setStatus(FileStatus.SUCCESS); + + // Save to recent files history after password submission + console.log('[App] Password flow complete - saving to history'); + saveToRecentFiles(files, combinedStatement, fileHashes); } } catch (err: any) { setStatus(FileStatus.PROTECTED); @@ -354,6 +369,10 @@ function App() { .catch(() => setExportLink("")); setExportFileName(fileName); setStatus(FileStatus.SUCCESS); + + // Save to recent files history after skip + console.log('[App] Skip flow complete - saving to history'); + saveToRecentFiles(files, combinedStatement, fileHashes); } else { setStatus(FileStatus.IDLE); setFiles([]); @@ -362,6 +381,70 @@ function App() { } }; + const saveToRecentFiles = ( + processedFiles: File[], + statement: MPesaStatement, + hashMap: Map + ) => { + console.log('[RecentFiles] saveToRecentFiles called'); + console.log('[RecentFiles] Processing', processedFiles.length, 'files'); + console.log('[RecentFiles] Statement has', statement.transactions.length, 'transactions'); + console.log('[RecentFiles] HashMap size:', hashMap.size); + + processedFiles.forEach((file, index) => { + const fileHash = hashMap.get(index); + console.log(`[RecentFiles] File ${index}:`, file.name, 'Hash:', fileHash ? fileHash.substring(0, 16) + '...' : 'NO HASH'); + + if (!fileHash) { + console.error('[RecentFiles] ❌ Skipping file - no hash found for index:', index); + return; + } + + // Calculate date range from transactions + const transactions = statement.transactions; + let dateRange = undefined; + if (transactions.length > 0) { + const dates = transactions.map(t => new Date(t.completionTime)); + const minDate = new Date(Math.min(...dates.map(d => d.getTime()))); + const maxDate = new Date(Math.max(...dates.map(d => d.getTime()))); + dateRange = { + from: minDate.toLocaleDateString(), + to: maxDate.toLocaleDateString(), + }; + console.log('[RecentFiles] Date range:', dateRange); + } + + // Calculate totals + const totalPaidIn = transactions.reduce((sum, t) => sum + (t.paidIn || 0), 0); + const totalWithdrawn = transactions.reduce((sum, t) => sum + (t.withdrawn || 0), 0); + const finalBalance = transactions.length > 0 + ? transactions[transactions.length - 1].balance + : 0; + + console.log('[RecentFiles] Totals - Paid In:', totalPaidIn, 'Withdrawn:', totalWithdrawn, 'Balance:', finalBalance); + + const recentFileEntry = { + id: `${fileHash}-${Date.now()}`, + fileName: file.name, + fileSize: file.size, + fileHash, + processedDate: Date.now(), + transactionCount: transactions.length, + isPasswordProtected: !!getPassword(fileHash), + dateRange, + totalPaidIn, + totalWithdrawn, + finalBalance, + }; + + console.log('[RecentFiles] Adding entry:', recentFileEntry); + addRecentFile(recentFileEntry); + console.log('[RecentFiles] ✅ Entry added successfully'); + }); + + console.log('[RecentFiles] saveToRecentFiles completed'); + }; + const handleReset = () => { setFiles([]); setFileHashes(new Map()); @@ -373,6 +456,7 @@ function App() { setDownloadSuccess(false); setSavedFilePath(""); setCachedPassword(null); + setShowHistory(false); if (exportLink) { URL.revokeObjectURL(exportLink); @@ -486,6 +570,23 @@ function App() { return (
+ + {/* History Modal Overlay */} + {showHistory && ( +
{ + if (e.target === e.currentTarget) { + setShowHistory(false); + } + }} + > +
+ setShowHistory(false)} /> +
+
+ )} +
@@ -614,17 +715,28 @@ function App() {