Complete Project Codebase
Full Source Code Reference
This section contains the complete source code for the Currency Denomination Distributor project, including all packages, scripts, and configuration files.
check/
?? check\AUTOMATIC_INSTALLATION.md
markdown
# Automatic Dependency Installation System
## Overview
This system automatically installs **all required dependencies** for the OCR-enabled Currency Distribution Backend, similar to how pm install` works for Node.js projects.
## What Gets Installed Automatically
### 1. **Tesseract OCR** (v5.3.3+)
- Optical Character Recognition engine
- Required for: Image text extraction, scanned PDF processing
- Installation: Silent install to `%LOCALAPPDATA%\CurrencyDistributor\Tesseract-OCR`
### 2. **Poppler** (v24.08.0+)
- PDF rendering utilities
- Required for: PDF to image conversion for OCR
- Installation: Extracted to `%LOCALAPPDATA%\CurrencyDistributor\poppler`
### 3. **Python Packages**
- **pytesseract** (=0.3.10) - Python wrapper for Tesseract
- **pillow** (=10.0.0) - Image processing
- **pdf2image** (=1.16.0) - PDF to image conversion
- **PyPDF2** (=3.0.0) - PDF text extraction
- **python-docx** (=1.1.0) - Word document processing
- **opencv-python** (=4.8.0) - Advanced image preprocessing
- **numpy** (=1.24.0) - Numerical operations
## Usage Methods
### Method 1: Double-Click Startup (Easiest) ?
1. Navigate to `packages/local-backend/`
2. Double-click **`START_BACKEND.bat`**
3. First run: Automatic installation begins (2-5 minutes)
4. Subsequent runs: Starts immediately
### Method 2: PowerShell Script
```powershell
cd packages/local-backend
.\start_with_auto_install.ps1
```
### Method 3: Existing Start Script (Enhanced)
```powershell
cd packages/local-backend
.\start.ps1
```
Now automatically detects and runs the auto-installer!
### Method 4: Manual Installation Only
```powershell
cd packages/local-backend
.\install_dependencies.ps1
```
Options:
- `-Force` - Reinstall all dependencies
- `-Silent` - Suppress console output
## How It Works
### First-Time Run
1. **Detection**: Checks if Tesseract and Poppler are installed
2. **Download**: Downloads installers from official sources
3. **Install**: Silently installs Tesseract and Poppler
4. **PATH Setup**: Adds tools to system PATH automatically
5. **Python Packages**: Installs all required Python libraries
6. **Verification**: Tests all installations
7. **Marker File**: Creates `.dependencies_installed` marker
8. **Server Start**: Launches backend server
**Time**: 2-5 minutes (download speed dependent)
### Subsequent Runs
1. **Quick Check**: Verifies marker file exists
2. **Validation**: Tests Tesseract and Poppler availability
3. **Server Start**: Immediately launches backend
**Time**: <5 seconds
## Installation Locations
### Tesseract OCR
```
Primary: %LOCALAPPDATA%\CurrencyDistributor\Tesseract-OCR\
Fallback Checks:
- C:\Program Files\Tesseract-OCR\
- C:\Program Files (x86)\Tesseract-OCR\
- System PATH
```
### Poppler
```
Primary: %LOCALAPPDATA%\CurrencyDistributor\poppler\
Fallback Checks:
- C:\Program Files\poppler\
- System PATH
```
### Installation Log
```
%LOCALAPPDATA%\CurrencyDistributor\install.log
```
## Offline Capability
? **Internet Required**: First-time installation only
? **Offline Mode**: All subsequent runs work completely offline
? **No Network Checks**: Application never requires internet after setup
## Features
### Intelligent Detection
- ? Skips installation if dependencies already exist
- ? Detects multiple installation locations
- ? Verifies PATH and direct executable access
- ? Handles both local and system-wide installations
### Error Handling
- ? Detailed logging to `install.log`
- ? Color-coded console output
- ? Graceful fallback on partial failures
- ? Continues server startup even if some packages fail
### PATH Management
- ? Adds tools to User PATH (not System PATH - no admin required)
- ? Updates current session PATH immediately
- ? Persists across terminal restarts
## Troubleshooting
### Issue: "Tesseract not found" after installation
**Solution 1**: Restart PowerShell/Terminal
```powershell
# Close and reopen your terminal
# Or reload PATH:
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
```
**Solution 2**: Force reinstall
```powershell
.\install_dependencies.ps1 -Force
```
### Issue: Python packages fail to install
**Solution**: Install with pre-built wheels
```powershell
python -m pip install --only-binary :all: numpy opencv-python
python -m pip install pytesseract pillow pdf2image PyPDF2 python-docx
```
### Issue: "Download failed"
**Causes**:
- No internet connection
- Firewall blocking downloads
- GitHub/external server down
**Solution**: Manual download
1. Download manually:
- Tesseract: https://github.com/UB-Mannheim/tesseract/wiki
- Poppler: https://github.com/oschwartz10612/poppler-windows/releases
2. Install manually:
```powershell
# Run installers
# Then mark as complete:
New-Item -ItemType File -Path "packages/local-backend/.dependencies_installed" -Force
```
### Issue: Script execution policy error
**Error**:
```
cannot be loaded because running scripts is disabled on this system
```
**Solution**:
```powershell
Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy Bypass -Force
```
Or run with bypass:
```powershell
PowerShell.exe -ExecutionPolicy Bypass -File start_with_auto_install.ps1
```
## Manual Verification
Check if everything is installed:
```powershell
# Test Tesseract
tesseract --version
# Test Poppler
pdftoppm -v
# Test Python packages
python -c "import pytesseract, PIL, pdf2image, PyPDF2, docx, cv2, numpy; print('All packages OK')"
```
## Force Reinstallation
To completely reinstall all dependencies:
```powershell
# Remove marker file
Remove-Item .dependencies_installed -Force
# Force reinstall
.\install_dependencies.ps1 -Force
```
## Architecture
### Scripts Overview
1. **`install_dependencies.ps1`**
- Core installer logic
- Downloads and installs Tesseract & Poppler
- Installs Python packages
- Verifies installations
- ~500 lines, fully automated
2. **`start_with_auto_install.ps1`**
- Entry point with dependency check
- Calls installer if needed
- Starts backend server
- Manages marker file
3. **`start.ps1`** (Enhanced)
- Original start script
- Now auto-detects and uses `start_with_auto_install.ps1`
- Backward compatible
4. **`START_BACKEND.bat`**
- Double-click launcher
- Runs PowerShell with bypass policy
- User-friendly entry point
### Dependency Flow
```
START_BACKEND.bat
↓
start_with_auto_install.ps1
↓
Check .dependencies_installed marker
↓
If missing → install_dependencies.ps1
↓
├─ Download Tesseract
├─ Install Tesseract
├─ Download Poppler
├─ Extract Poppler
├─ Update PATH
├─ Install Python packages
└─ Verify all installations
↓
Create .dependencies_installed marker
↓
Start backend server (uvicorn)
```
## Supported File Formats (After Installation)
With all dependencies installed, the backend supports:
- ? **CSV** - Direct parsing (no OCR needed)
- ? **Word (.docx)** - Text extraction (no OCR needed)
- ? **PDF (text-based)** - Direct text extraction (no OCR needed)
- ? **PDF (scanned)** - OCR processing with Tesseract
- ? **Images** - JPG, PNG, TIFF, BMP with OCR
## Development Notes
### Adding New Dependencies
To add a new Python package:
1. Update `requirements.txt`
2. Add to `$packages` array in `install_dependencies.ps1`:
```powershell
$packages = @(
"pytesseract>=0.3.10",
"your-new-package>=1.0.0" # Add here
)
```
### Adding New Binary Tools
To add a new tool (like Tesseract/Poppler):
1. Add download URL constant
2. Create `Install-YourTool` function
3. Add to `Start-Installation` flow
4. Add to `Test-AllDependencies` verification
## Security
- ? Downloads from official sources only
- ? HTTPS connections
- ? No admin rights required (User PATH only)
- ? Local installation directory (sandboxed)
- ? No external script execution
## License & Credits
### Tesseract OCR
- License: Apache 2.0
- Source: https://github.com/tesseract-ocr/tesseract
- Maintained by: Google & Contributors
### Poppler
- License: GPL
- Source: https://poppler.freedesktop.org/
- Windows Build: https://github.com/oschwartz10612/poppler-windows
### Python Packages
- Various open-source licenses (MIT, BSD, Apache)
- See individual package documentation
---
## Quick Reference
| Command | Purpose |
|---------|---------|
| `START_BACKEND.bat` | Double-click easy start |
| `.\start_with_auto_install.ps1` | Auto-install + start |
| `.\start.ps1` | Legacy start (now auto-enhanced) |
| `.\install_dependencies.ps1` | Install dependencies only |
| `.\install_dependencies.ps1 -Force` | Force reinstall everything |
| `tesseract --version` | Verify Tesseract |
| `pdftoppm -v` | Verify Poppler |
---
**Ready to use!** Just double-click `START_BACKEND.bat` and everything installs automatically! ??
?? check\BULK_UPLOAD.md
markdown
# Bulk CSV Upload Feature
## Overview
The bulk CSV upload feature allows users to process multiple denomination calculations in a single request by uploading a CSV file. This is useful for:
- Batch processing of multiple amounts
- Automated calculations from spreadsheets
- Migrating existing calculation data
- Testing multiple scenarios
## API Endpoint
### POST `/api/v1/bulk-upload`
Upload a CSV file containing multiple calculation requests.
**Request:**
- Method: `POST`
- Content-Type: `multipart/form-data`
- Parameters:
- `file`: CSV file (required)
- `save_to_history`: Boolean (default: true) - Whether to save results to history
- `language`: String (default: 'en') - Language code for smart currency defaults (en, hi, es, fr, de)
**Response:**
```json
{
"total_rows": 10,
"successful": 9,
"failed": 1,
"processing_time_seconds": 0.523,
"saved_to_history": true,
"results": [
{
"row_number": 2,
"status": "success",
"amount": "50000",
"currency": "INR",
"optimization_mode": "greedy",
"total_notes": 25,
"total_coins": 0,
"total_denominations": 25,
"breakdowns": [...],
"calculation_id": 123
},
{
"row_number": 8,
"status": "error",
"amount": "invalid",
"currency": "INR",
"error": "Invalid amount format: invalid"
}
]
}
```
## CSV Format
### Required Columns
- `amount`: Numeric value (supports decimals and large numbers)
- **Case-Insensitive Header**: `amount`, `Amount`, `AMOUNT` all work
### Optional Columns
- `currency`: 3-letter currency code (INR, USD, EUR, GBP)
- **Case-Insensitive Header**: `currency`, `Currency`, `CURRENCY` all work
- **Case-Insensitive Value**: `USD`, `usd`, `Usd` are all valid
- **Smart Default**: If not provided, defaults based on your language:
- English (en) → USD
- Hindi (hi) → INR
- Spanish (es) → EUR
- French (fr) → EUR
- German (de) → EUR
- `optimization_mode`: One of:
- **Case-Insensitive Header**: `optimization_mode`, `Optimization_Mode`, `OPTIMIZATION_MODE` all work
- **Case-Insensitive Value**: `GREEDY`, `greedy`, `Greedy` are all valid
- `greedy` (default if not provided) - Minimize total denominations
- `balanced` - Balance between notes and coins
- `minimize_large` - Minimize large denominations
- `minimize_small` - Minimize small denominations
### Case-Insensitive Processing
The system is **completely case-insensitive** for:
- ? **Column headers**: `Amount`, `AMOUNT`, `amount` all recognized
- ? **Currency values**: `USD`, `usd`, `UsD` all valid
- ? **Optimization values**: `GREEDY`, `greedy`, `Greedy` all valid
This means you can use ANY casing you prefer!
### Example CSV
```csv
Amount,Currency,Optimization_Mode
50000,INR,greedy
1000.50,usd,Balanced
5000,,minimize_large
250000
999.99,GBP,GREEDY
7500,eur
```
**Alternative valid headers** (all work the same):
```csv
AMOUNT,CURRENCY,OPTIMIZATION_MODE
amount,currency,optimization_mode
Amount,Currency,Optimization_Mode
```
**Note**: Rows demonstrate:
- Row 1: Standard case
- Row 2: Mixed case values
- Row 3: No currency (uses language default), has optimization
- Row 4: Only amount (uses both defaults)
- Row 5: Uppercase optimization
- Row 6: Currency only, lowercase (uses greedy default)
### File Requirements
- **Format**: CSV (Comma-Separated Values)
- **Encoding**: UTF-8 recommended
- **First Row**: Must be headers
- **File Extension**: `.csv`
- **Max Size**: No hard limit, but large files may take longer to process
## Validation
The API validates each row and provides detailed error messages:
### Amount Validation
- Must be present
- Must be a valid number (supports decimals)
- Must be positive (> 0)
- Supports large numbers as strings
### Currency Validation (Optional)
- If provided, must be exactly 3 characters
- If provided, must be a supported currency (INR, USD, EUR, GBP)
- If not provided, defaults based on language parameter
- Case-insensitive (USD, usd, Usd all work)
### Optimization Mode Validation (Optional)
- If provided, must be one of: greedy, balanced, minimize_large, minimize_small
- If not provided, defaults to "greedy"
- If invalid, defaults to "greedy" (no error thrown)
- Case-insensitive (GREEDY, greedy, Greedy all work)
## Error Handling
### Row-Level Errors
Invalid rows are marked as "error" status with specific error messages:
- `"Amount is required"` - Missing amount
- `"Currency must be 3-letter code (e.g., INR, USD), got: X"` - Invalid currency format (only if currency is provided but invalid)
- `"Invalid amount format: X"` - Cannot parse amount
- `"Amount must be positive"` - Negative or zero amount
- `"Unexpected error: X"` - Other processing errors
**Note**: Missing currency or optimization mode are NOT errors - they use smart defaults based on language and greedy mode respectively.
### File-Level Errors
- **400 Bad Request**: Invalid file format, encoding issues, missing required column (amount)
- **500 Internal Server Error**: Unexpected processing failures
### Partial Success
The API processes all rows and returns results for both successful and failed rows. A single invalid row does not stop processing of other rows.
## Response Fields
### Summary Fields
- `total_rows`: Total number of rows processed (excluding header)
- `successful`: Count of successfully processed rows
- `failed`: Count of failed rows
- `processing_time_seconds`: Time taken to process all rows
- `saved_to_history`: Whether results were saved to database
### Result Fields (per row)
**Success Response:**
- `row_number`: CSV row number (starts at 2, since 1 is header)
- `status`: "success"
- `amount`: Processed amount
- `currency`: Currency code
- `optimization_mode`: Applied optimization mode
- `total_notes`: Count of notes in breakdown
- `total_coins`: Count of coins in breakdown
- `total_denominations`: Total count of all denominations
- `breakdowns`: Array of denomination details
- `calculation_id`: Database ID (if saved to history)
**Error Response:**
- `row_number`: CSV row number
- `status`: "error"
- `amount`: Attempted amount (may be invalid)
- `currency`: Attempted currency (may be invalid)
- `optimization_mode`: Attempted mode (may be invalid)
- `error`: Detailed error message
## Usage Examples
### cURL Example
```bash
curl -X POST "http://localhost:8001/api/v1/bulk-upload?save_to_history=true" \
-H "accept: application/json" \
-H "Content-Type: multipart/form-data" \
-F "file=@sample_bulk_upload.csv"
```
### Python Example
```python
import requests
url = "http://localhost:8001/api/v1/bulk-upload"
files = {"file": open("sample_bulk_upload.csv", "rb")}
params = {"save_to_history": True}
response = requests.post(url, files=files, params=params)
result = response.json()
print(f"Processed {result['total_rows']} rows")
print(f"Success: {result['successful']}, Failed: {result['failed']}")
print(f"Processing time: {result['processing_time_seconds']}s")
# Check for errors
for row in result['results']:
if row['status'] == 'error':
print(f"Row {row['row_number']}: {row['error']}")
```
### JavaScript Example
```javascript
const formData = new FormData();
formData.append('file', fileInput.files[0]);
const response = await fetch('http://localhost:8001/api/v1/bulk-upload?save_to_history=true', {
method: 'POST',
body: formData
});
const result = await response.json();
console.log(`Processed ${result.total_rows} rows`);
console.log(`Success: ${result.successful}, Failed: ${result.failed}`);
// Display results
result.results.forEach(row => {
if (row.status === 'success') {
console.log(`Row ${row.row_number}: ${row.amount} ${row.currency} -> ${row.total_denominations} denominations`);
} else {
console.error(`Row ${row.row_number}: ${row.error}`);
}
});
```
## Performance Considerations
### Processing Speed
- Typical processing: ~50-100 rows/second
- Large files (1000+ rows): May take 10-20 seconds
- Processing is synchronous - response waits for all rows
### Database Impact
- If `save_to_history=true`, each successful row creates a database entry
- Uses individual commits per row for reliability
- Failed rows do not create database entries
### Memory Usage
- Entire file is loaded into memory
- Large files (10MB+) may require more server memory
- Consider splitting very large files (10,000+ rows)
## Best Practices
1. **Test with Small Files First**
- Start with 10-20 rows to verify format
- Check error messages for validation issues
2. **Use UTF-8 Encoding**
- Ensures proper handling of currency symbols
- Prevents encoding-related errors
3. **Include Headers**
- First row must contain column names
- Use exact names: `amount`, `currency`, `optimization_mode`
4. **Validate Data Before Upload**
- Ensure all amounts are valid numbers
- Verify currency codes are 3 letters
- Check for empty rows
5. **Handle Partial Failures**
- Always check the `failed` count in response
- Review error messages for failed rows
- Re-upload corrected rows if needed
6. **Monitor Processing Time**
- Use `processing_time_seconds` to gauge performance
- Split large files if processing takes too long
## Troubleshooting
### Common Issues
**"CSV must contain required columns"**
- Solution: Ensure first row has headers: `amount,currency`
**"File encoding error"**
- Solution: Save CSV as UTF-8 encoding
**"Invalid amount format"**
- Solution: Check for non-numeric characters in amount column
**"Currency must be 3-letter code"**
- Solution: Use standard codes (INR, USD, EUR, GBP)
**"File must be a CSV file"**
- Solution: Ensure file extension is `.csv`
### Debugging Tips
1. Check the `row_number` in error responses
2. Review the original CSV file at that line
3. Verify column values match requirements
4. Test individual rows via `/api/v1/calculate` endpoint
5. Check API documentation at `/docs`
## Integration with Desktop App
The desktop application can integrate this feature with:
1. **File Upload Button**
```jsx
<input
type="file"
accept=".csv"
onChange={handleFileUpload}
/>
```
2. **Progress Indicator**
- Show upload progress
- Display processing status
- Update when complete
3. **Results Display**
- Show success/failure summary
- List successful calculations
- Highlight errors with row numbers
4. **Error Handling**
- Display user-friendly error messages
- Allow re-upload of corrected file
- Provide CSV template download
## Future Enhancements
- [ ] Excel (.xlsx) file support
- [ ] Real-time progress updates for large files
- [ ] Async processing for very large files
- [ ] Download template CSV
- [ ] Batch export of results
- [ ] Validation preview before processing
- [ ] Support for additional columns (notes, tags, etc.)
## API Documentation
Full interactive documentation available at:
- Swagger UI: http://localhost:8001/docs
- ReDoc: http://localhost:8001/redoc
## Support
For issues or questions:
1. Check this documentation
2. Review API docs at `/docs`
3. Check sample CSV file
4. Test with minimal example
?? check\check_dependencies copy.ps1
powershell
# Simple Dependency Checker and Installer
# This script checks for required dependencies and helps install them
$ErrorActionPreference = "Continue"
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host "Currency Distributor - Dependency Checker" -ForegroundColor Cyan
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host ""
$allInstalled = $true
# Check Tesseract
Write-Host "[1/3] Checking Tesseract OCR..." -ForegroundColor Yellow
$tesseractFound = $false
$tesseractPaths = @(
"C:\Program Files\Tesseract-OCR\tesseract.exe",
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
"$env:LOCALAPPDATA\CurrencyDistributor\Tesseract-OCR\tesseract.exe"
)
foreach ($path in $tesseractPaths) {
if (Test-Path $path) {
Write-Host " [OK] Tesseract found at: $path" -ForegroundColor Green
$tesseractFound = $true
# Add to PATH if not already there
$tesseractDir = Split-Path $path -Parent
if ($env:PATH -notlike "*$tesseractDir*") {
$env:PATH += ";$tesseractDir"
Write-Host " [OK] Added to current session PATH" -ForegroundColor Green
}
break
}
}
if (-not $tesseractFound) {
try {
$null = & tesseract --version 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Tesseract found in PATH" -ForegroundColor Green
$tesseractFound = $true
}
} catch {}
}
if (-not $tesseractFound) {
Write-Host " [MISSING] Tesseract NOT found" -ForegroundColor Red
Write-Host " Please download and install from:" -ForegroundColor Yellow
Write-Host " https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-5.4.0.20240606.exe" -ForegroundColor Yellow
$allInstalled = $false
}
Write-Host ""
# Check Poppler
Write-Host "[2/3] Checking Poppler..." -ForegroundColor Yellow
$popplerFound = $false
$popplerPaths = @(
"C:\Program Files\poppler\Library\bin\pdftoppm.exe",
"C:\Program Files\poppler\poppler-24.08.0\Library\bin\pdftoppm.exe",
"$env:LOCALAPPDATA\CurrencyDistributor\poppler\poppler-24.08.0\Library\bin\pdftoppm.exe"
)
foreach ($path in $popplerPaths) {
if (Test-Path $path) {
Write-Host " [OK] Poppler found at: $path" -ForegroundColor Green
$popplerFound = $true
# Add to PATH if not already there
$popplerDir = Split-Path $path -Parent
if ($env:PATH -notlike "*$popplerDir*") {
$env:PATH += ";$popplerDir"
Write-Host " [OK] Added to current session PATH" -ForegroundColor Green
}
break
}
}
if (-not $popplerFound) {
try {
$null = & pdftoppm -v 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Poppler found in PATH" -ForegroundColor Green
$popplerFound = $true
}
} catch {}
}
if (-not $popplerFound) {
Write-Host " [MISSING] Poppler NOT found" -ForegroundColor Red
Write-Host " Please download and extract from:" -ForegroundColor Yellow
Write-Host " https://github.com/oschwartz10612/poppler-windows/releases/download/v24.08.0-0/Release-24.08.0-0.zip" -ForegroundColor Yellow
Write-Host " Extract to: C:\Program Files\poppler" -ForegroundColor Yellow
$allInstalled = $false
}
Write-Host ""
# Check Python packages
Write-Host "[3/3] Checking Python packages..." -ForegroundColor Yellow
try {
$pythonCheck = & python --version 2>&1
Write-Host " [OK] Python: $pythonCheck" -ForegroundColor Green
$packages = @("pytesseract", "PIL", "pdf2image", "PyPDF2", "docx", "cv2", "numpy")
$missing = @()
foreach ($pkg in $packages) {
try {
$result = & python -c "import $pkg; print('OK')" 2>&1
if ($result -like "*OK*") {
Write-Host " [OK] $pkg" -ForegroundColor Green
} else {
Write-Host " [MISSING] $pkg" -ForegroundColor Red
$missing += $pkg
}
} catch {
Write-Host " [MISSING] $pkg" -ForegroundColor Red
$missing += $pkg
}
}
if ($missing.Count -gt 0) {
Write-Host ""
Write-Host " Missing packages detected. Installing..." -ForegroundColor Yellow
$pkgMap = @{
"PIL" = "pillow"
"cv2" = "opencv-python"
"docx" = "python-docx"
}
foreach ($pkg in $missing) {
$installName = if ($pkgMap.ContainsKey($pkg)) { $pkgMap[$pkg] } else { $pkg }
Write-Host " Installing $installName..." -ForegroundColor Yellow
if ($installName -match "numpy|opencv") {
& python -m pip install --only-binary :all: $installName --quiet
} else {
& python -m pip install $installName --quiet
}
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Installed $installName" -ForegroundColor Green
} else {
Write-Host " [FAILED] Failed to install $installName" -ForegroundColor Red
$allInstalled = $false
}
}
}
} catch {
Write-Host " [MISSING] Python not found!" -ForegroundColor Red
Write-Host " Please install Python 3.8+ from https://www.python.org/" -ForegroundColor Yellow
$allInstalled = $false
}
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Cyan
if ($allInstalled) {
Write-Host "All dependencies are installed!" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host ""
# Create marker file
New-Item -Path ".dependencies_installed" -ItemType File -Force | Out-Null
exit 0
} else {
Write-Host "Some dependencies are missing!" -ForegroundColor Red
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Please install the missing components and run this script again." -ForegroundColor Yellow
Write-Host ""
Write-Host "Quick Installation Guide:" -ForegroundColor Cyan
Write-Host "1. Download Tesseract from the URL above and install it" -ForegroundColor White
Write-Host "2. Download Poppler ZIP and extract to C:\Program Files\poppler" -ForegroundColor White
Write-Host "3. Run this script again to verify and install Python packages" -ForegroundColor White
Write-Host ""
exit 1
}
?? check\check_dependencies.ps1
powershell
# Simple Dependency Checker and Installer
# This script checks for required dependencies and helps install them
$ErrorActionPreference = "Continue"
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host "Currency Distributor - Dependency Checker" -ForegroundColor Cyan
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host ""
$allInstalled = $true
# Check Tesseract
Write-Host "[1/3] Checking Tesseract OCR..." -ForegroundColor Yellow
$tesseractFound = $false
$tesseractPaths = @(
"C:\Program Files\Tesseract-OCR\tesseract.exe",
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe",
"$env:LOCALAPPDATA\CurrencyDistributor\Tesseract-OCR\tesseract.exe"
)
foreach ($path in $tesseractPaths) {
if (Test-Path $path) {
Write-Host " [OK] Tesseract found at: $path" -ForegroundColor Green
$tesseractFound = $true
# Add to PATH if not already there
$tesseractDir = Split-Path $path -Parent
if ($env:PATH -notlike "*$tesseractDir*") {
$env:PATH += ";$tesseractDir"
Write-Host " [OK] Added to current session PATH" -ForegroundColor Green
}
break
}
}
if (-not $tesseractFound) {
try {
$null = & tesseract --version 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Tesseract found in PATH" -ForegroundColor Green
$tesseractFound = $true
}
} catch {}
}
if (-not $tesseractFound) {
Write-Host " [MISSING] Tesseract NOT found" -ForegroundColor Red
Write-Host " Please download and install from:" -ForegroundColor Yellow
Write-Host " https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-5.4.0.20240606.exe" -ForegroundColor Yellow
$allInstalled = $false
}
Write-Host ""
# Check Poppler
Write-Host "[2/3] Checking Poppler..." -ForegroundColor Yellow
$popplerFound = $false
$popplerPaths = @(
"C:\Program Files\poppler\Library\bin\pdftoppm.exe",
"C:\Program Files\poppler\poppler-24.08.0\Library\bin\pdftoppm.exe",
"$env:LOCALAPPDATA\CurrencyDistributor\poppler\poppler-24.08.0\Library\bin\pdftoppm.exe"
)
foreach ($path in $popplerPaths) {
if (Test-Path $path) {
Write-Host " [OK] Poppler found at: $path" -ForegroundColor Green
$popplerFound = $true
# Add to PATH if not already there
$popplerDir = Split-Path $path -Parent
if ($env:PATH -notlike "*$popplerDir*") {
$env:PATH += ";$popplerDir"
Write-Host " [OK] Added to current session PATH" -ForegroundColor Green
}
break
}
}
if (-not $popplerFound) {
try {
$null = & pdftoppm -v 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Poppler found in PATH" -ForegroundColor Green
$popplerFound = $true
}
} catch {}
}
if (-not $popplerFound) {
Write-Host " [MISSING] Poppler NOT found" -ForegroundColor Red
Write-Host " Please download and extract from:" -ForegroundColor Yellow
Write-Host " https://github.com/oschwartz10612/poppler-windows/releases/download/v24.08.0-0/Release-24.08.0-0.zip" -ForegroundColor Yellow
Write-Host " Extract to: C:\Program Files\poppler" -ForegroundColor Yellow
$allInstalled = $false
}
Write-Host ""
# Check Python packages
Write-Host "[3/3] Checking Python packages..." -ForegroundColor Yellow
try {
$pythonCheck = & python --version 2>&1
Write-Host " [OK] Python: $pythonCheck" -ForegroundColor Green
$packages = @("pytesseract", "PIL", "pdf2image", "PyPDF2", "docx", "cv2", "numpy")
$missing = @()
foreach ($pkg in $packages) {
try {
$result = & python -c "import $pkg; print('OK')" 2>&1
if ($result -like "*OK*") {
Write-Host " [OK] $pkg" -ForegroundColor Green
} else {
Write-Host " [MISSING] $pkg" -ForegroundColor Red
$missing += $pkg
}
} catch {
Write-Host " [MISSING] $pkg" -ForegroundColor Red
$missing += $pkg
}
}
if ($missing.Count -gt 0) {
Write-Host ""
Write-Host " Missing packages detected. Installing..." -ForegroundColor Yellow
$pkgMap = @{
"PIL" = "pillow"
"cv2" = "opencv-python"
"docx" = "python-docx"
}
foreach ($pkg in $missing) {
$installName = if ($pkgMap.ContainsKey($pkg)) { $pkgMap[$pkg] } else { $pkg }
Write-Host " Installing $installName..." -ForegroundColor Yellow
if ($installName -match "numpy|opencv") {
& python -m pip install --only-binary :all: $installName --quiet
} else {
& python -m pip install $installName --quiet
}
if ($LASTEXITCODE -eq 0) {
Write-Host " [OK] Installed $installName" -ForegroundColor Green
} else {
Write-Host " [FAILED] Failed to install $installName" -ForegroundColor Red
$allInstalled = $false
}
}
}
} catch {
Write-Host " [MISSING] Python not found!" -ForegroundColor Red
Write-Host " Please install Python 3.8+ from https://www.python.org/" -ForegroundColor Yellow
$allInstalled = $false
}
Write-Host ""
Write-Host "=========================================================" -ForegroundColor Cyan
if ($allInstalled) {
Write-Host "All dependencies are installed!" -ForegroundColor Green
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host ""
# Create marker file
New-Item -Path ".dependencies_installed" -ItemType File -Force | Out-Null
exit 0
} else {
Write-Host "Some dependencies are missing!" -ForegroundColor Red
Write-Host "=========================================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Please install the missing components and run this script again." -ForegroundColor Yellow
Write-Host ""
Write-Host "Quick Installation Guide:" -ForegroundColor Cyan
Write-Host "1. Download Tesseract from the URL above and install it" -ForegroundColor White
Write-Host "2. Download Poppler ZIP and extract to C:\Program Files\poppler" -ForegroundColor White
Write-Host "3. Run this script again to verify and install Python packages" -ForegroundColor White
Write-Host ""
exit 1
}
?? check\install_dependencies copy.ps1
powershell
# Fully Automatic Dependency Installer - Currency Distributor Backend
# This script downloads and installs ALL dependencies without user intervention
param(
[switch]$Force
)
$ErrorActionPreference = "Continue"
$ProgressPreference = "SilentlyContinue"
# Configuration
$INSTALL_DIR = "$env:LOCALAPPDATA\CurrencyDistributor"
$TESSERACT_DIR = "$INSTALL_DIR\Tesseract-OCR"
$POPPLER_DIR = "$INSTALL_DIR\poppler"
$INSTALL_LOG = "$INSTALL_DIR\install.log"
$TEMP_DIR = "$env:TEMP\CurrencyDistributor"
# Create directories
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
New-Item -ItemType Directory -Path $TEMP_DIR -Force | Out-Null
# Logging function
function Write-Log {
param($Message, $Color = "White")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage -ForegroundColor $Color
Add-Content -Path $INSTALL_LOG -Value $logMessage -ErrorAction SilentlyContinue
}
Write-Log "=========================================================" "Cyan"
Write-Log "Automatic Dependency Installer - Starting..." "Cyan"
Write-Log "=========================================================" "Cyan"
# Advanced download function with multiple fallback methods
function Download-FileAdvanced {
param(
[string]$Url,
[string]$OutputPath,
[string]$Description
)
Write-Log "Downloading $Description..." "Yellow"
Write-Log "URL: $Url" "Gray"
# Ensure output directory exists
$outputDir = Split-Path $OutputPath -Parent
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
# Remove existing file
if (Test-Path $OutputPath) {
Remove-Item $OutputPath -Force -ErrorAction SilentlyContinue
}
# Method 1: Try .NET WebClient (fastest)
try {
Write-Log " Attempting .NET WebClient download..." "Gray"
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
$webClient.Proxy = [System.Net.WebRequest]::DefaultWebProxy
$webClient.Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
# Synchronous download with timeout handling
$downloadTask = $webClient.DownloadFileTaskAsync($Url, $OutputPath)
$timeoutTask = [System.Threading.Tasks.Task]::Delay(600000) # 10 minute timeout
$completedTask = [System.Threading.Tasks.Task]::WaitAny(@($downloadTask, $timeoutTask))
if ($completedTask -eq 0 -and (Test-Path $OutputPath)) {
$size = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
Write-Log " SUCCESS: Downloaded $size MB via WebClient" "Green"
$webClient.Dispose()
return $true
} else {
Write-Log " WebClient timed out or failed" "Yellow"
$webClient.Dispose()
}
} catch {
Write-Log " WebClient failed: $($_.Exception.Message)" "Yellow"
}
# Method 2: BITS Transfer (Windows Background Intelligent Transfer)
try {
Write-Log " Attempting BITS Transfer..." "Gray"
Import-Module BitsTransfer -ErrorAction Stop
Start-BitsTransfer `
-Source $Url `
-Destination $OutputPath `
-Priority High `
-RetryInterval 60 `
-RetryTimeout 300 `
-ErrorAction Stop
if (Test-Path $OutputPath) {
$size = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
Write-Log " SUCCESS: Downloaded $size MB via BITS" "Green"
return $true
}
} catch {
Write-Log " BITS Transfer failed: $($_.Exception.Message)" "Yellow"
}
# Method 3: Invoke-WebRequest with custom headers
try {
Write-Log " Attempting Invoke-WebRequest..." "Gray"
$headers = @{
'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
'Accept' = '*/*'
'Accept-Encoding' = 'gzip, deflate, br'
'Connection' = 'keep-alive'
}
Invoke-WebRequest `
-Uri $Url `
-OutFile $OutputPath `
-Headers $headers `
-UseBasicParsing `
-TimeoutSec 600 `
-MaximumRedirection 10 `
-ErrorAction Stop
if (Test-Path $OutputPath) {
$size = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
Write-Log " SUCCESS: Downloaded $size MB via Invoke-WebRequest" "Green"
return $true
}
} catch {
Write-Log " Invoke-WebRequest failed: $($_.Exception.Message)" "Yellow"
}
# Method 4: Alternative URLs/Mirrors
if ($Url -like "*tesseract*") {
Write-Log " Trying alternative Tesseract source..." "Yellow"
$altUrls = @(
"https://github.com/UB-Mannheim/tesseract/releases/download/v5.4.0.20240606/tesseract-ocr-w64-setup-5.4.0.20240606.exe",
"https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-5.3.3.20231005.exe"
)
foreach ($altUrl in $altUrls) {
try {
Write-Log " Trying: $altUrl" "Gray"
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("User-Agent", "Mozilla/5.0")
$webClient.DownloadFile($altUrl, $OutputPath)
$webClient.Dispose()
if (Test-Path $OutputPath) {
$size = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
if ($size -gt 10) { # Valid installer should be > 10MB
Write-Log " SUCCESS: Downloaded from alternative source ($size MB)" "Green"
return $true
}
}
} catch {
Write-Log " Alternative URL failed: $($_.Exception.Message)" "Yellow"
}
}
}
Write-Log " FAILED: All download methods exhausted" "Red"
return $false
}
# Install Tesseract OCR
function Install-Tesseract {
Write-Log "" "White"
Write-Log "[1/3] Installing Tesseract OCR..." "Cyan"
# Check if already installed
$tesseractExe = "$TESSERACT_DIR\tesseract.exe"
if ((Test-Path $tesseractExe) -and -not $Force) {
Write-Log " Tesseract already installed at: $TESSERACT_DIR" "Green"
Add-ToUserPath -Path $TESSERACT_DIR
return $true
}
# Check system-wide installation
$systemPaths = @(
"C:\Program Files\Tesseract-OCR\tesseract.exe",
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
)
foreach ($path in $systemPaths) {
if (Test-Path $path) {
Write-Log " Found system Tesseract at: $path" "Green"
$script:TESSERACT_DIR = Split-Path $path -Parent
Add-ToUserPath -Path $script:TESSERACT_DIR
return $true
}
}
# Download installer
$installerUrl = "https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-5.4.0.20240606.exe"
$installerPath = "$TEMP_DIR\tesseract-installer.exe"
Write-Log " Downloading Tesseract installer..." "Yellow"
if (-not (Download-FileAdvanced -Url $installerUrl -OutputPath $installerPath -Description "Tesseract OCR")) {
Write-Log " ERROR: Failed to download Tesseract installer" "Red"
return $false
}
# Verify download
if (-not (Test-Path $installerPath)) {
Write-Log " ERROR: Installer file not found after download" "Red"
return $false
}
$fileSize = (Get-Item $installerPath).Length
if ($fileSize -lt 10MB) {
Write-Log " ERROR: Downloaded file too small ($fileSize bytes), likely corrupted" "Red"
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
return $false
}
# Install silently
try {
Write-Log " Installing Tesseract (this may take 1-2 minutes)..." "Yellow"
Write-Log " Target directory: $TESSERACT_DIR" "Gray"
# Create installation directory
New-Item -ItemType Directory -Path $TESSERACT_DIR -Force | Out-Null
# Run installer using Start-Process with proper flags
$arguments = "/VERYSILENT /NORESTART /DIR=`"$TESSERACT_DIR`""
Write-Log " Executing: $installerPath $arguments" "Gray"
$process = Start-Process -FilePath $installerPath -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden
$exitCode = $process.ExitCode
Write-Log " Installer exit code: $exitCode" "Gray"
# Wait a bit for filesystem to settle
Start-Sleep -Seconds 3
# Verify installation
if (Test-Path "$TESSERACT_DIR\tesseract.exe") {
Write-Log " SUCCESS: Tesseract installed successfully!" "Green"
# Clean up installer
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
# Add to PATH
Add-ToUserPath -Path $TESSERACT_DIR
return $true
} else {
Write-Log " ERROR: Installation completed but tesseract.exe not found" "Red"
Write-Log " Checked: $TESSERACT_DIR\tesseract.exe" "Red"
# List what's in the directory for debugging
if (Test-Path $TESSERACT_DIR) {
Write-Log " Directory contents:" "Gray"
Get-ChildItem $TESSERACT_DIR -Recurse -File | Select-Object -First 10 | ForEach-Object {
Write-Log " $($_.FullName)" "Gray"
}
}
return $false
}
} catch {
Write-Log " ERROR: Installation failed - $($_.Exception.Message)" "Red"
return $false
}
}
# Install Poppler
function Install-Poppler {
Write-Log "" "White"
Write-Log "[2/3] Installing Poppler..." "Cyan"
# Function to test if Poppler is functional
function Test-PopplerFunctional {
param([string]$BinPath)
if (-not (Test-Path $BinPath)) {
return $false
}
$pdfToPpmExe = Join-Path $BinPath "pdftoppm.exe"
if (-not (Test-Path $pdfToPpmExe)) {
return $false
}
# Test if command actually works
try {
$testOutput = & $pdfToPpmExe -v 2>&1
if ($testOutput -match "pdftoppm version" -or $testOutput -match "poppler") {
return $true
}
} catch {
return $false
}
return $false
}
# Check local installation
$localBinPath = "$POPPLER_DIR\poppler-24.08.0\Library\bin"
if (Test-PopplerFunctional -BinPath $localBinPath) {
Write-Log " Poppler already installed and functional" "Green"
Add-ToUserPath -Path $localBinPath
return $true
}
# Check system installation
$systemBinPath = "C:\Program Files\poppler\Library\bin"
if (Test-PopplerFunctional -BinPath $systemBinPath) {
Write-Log " Found functional system Poppler installation" "Green"
Add-ToUserPath -Path $systemBinPath
return $true
}
# If directory exists but not functional, remove it
if (Test-Path $POPPLER_DIR) {
Write-Log " Found non-functional Poppler installation, removing..." "Yellow"
Remove-Item $POPPLER_DIR -Recurse -Force -ErrorAction SilentlyContinue
}
# Download Poppler
$popplerUrl = "https://github.com/oschwartz10612/poppler-windows/releases/download/v24.08.0-0/Release-24.08.0-0.zip"
$zipPath = "$TEMP_DIR\poppler.zip"
Write-Log " Downloading Poppler..." "Yellow"
if (-not (Download-FileAdvanced -Url $popplerUrl -OutputPath $zipPath -Description "Poppler")) {
Write-Log " ERROR: Failed to download Poppler" "Red"
return $false
}
# Verify download
if (-not (Test-Path $zipPath)) {
Write-Log " ERROR: ZIP file not found after download" "Red"
return $false
}
# Extract
try {
Write-Log " Extracting Poppler..." "Yellow"
# Remove old installation
if (Test-Path $POPPLER_DIR) {
Remove-Item $POPPLER_DIR -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Path $POPPLER_DIR -Force | Out-Null
# Extract ZIP
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $POPPLER_DIR)
Write-Log " SUCCESS: Poppler extracted successfully!" "Green"
# Clean up ZIP
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
# Find bin directory and add to PATH
$binDirs = Get-ChildItem -Path $POPPLER_DIR -Recurse -Directory -Filter "bin" -ErrorAction SilentlyContinue
$foundWorking = $false
foreach ($binDir in $binDirs) {
$pdfToPpmPath = Join-Path $binDir.FullName "pdftoppm.exe"
if (Test-Path $pdfToPpmPath) {
Write-Log " Found Poppler bin: $($binDir.FullName)" "Green"
# Test if it's functional
if (Test-PopplerFunctional -BinPath $binDir.FullName) {
Write-Log " Verified Poppler is functional" "Green"
Add-ToUserPath -Path $binDir.FullName
$foundWorking = $true
break
} else {
Write-Log " WARNING: Found pdftoppm.exe but it's not responding correctly" "Yellow"
}
}
}
if ($foundWorking) {
return $true
} else {
Write-Log " ERROR: Poppler extracted but no functional installation found" "Red"
return $false
}
} catch {
Write-Log " ERROR: Extraction failed - $($_.Exception.Message)" "Red"
return $false
}
}
# Install Python packages
function Install-PythonPackages {
Write-Log "" "White"
Write-Log "[3/3] Installing Python Packages..." "Cyan"
# Check Python
try {
$pythonVersion = & python --version 2>&1
Write-Log " Python version: $pythonVersion" "Green"
} catch {
Write-Log " ERROR: Python not found. Please install Python 3.8+" "Red"
return $false
}
# Upgrade pip
Write-Log " Upgrading pip..." "Yellow"
& python -m pip install --upgrade pip --quiet 2>&1 | Out-Null
# Package list
$packages = @(
"pytesseract",
"pillow",
"pdf2image",
"PyPDF2",
"python-docx",
"opencv-python",
"numpy"
)
Write-Log " Installing Python packages..." "Yellow"
$failed = @()
foreach ($pkg in $packages) {
try {
Write-Log " Installing $pkg..." "Gray"
if ($pkg -match "numpy|opencv") {
$output = & python -m pip install --only-binary :all: $pkg --quiet 2>&1
} else {
$output = & python -m pip install $pkg --quiet 2>&1
}
if ($LASTEXITCODE -eq 0) {
Write-Log " SUCCESS: $pkg installed" "Green"
} else {
Write-Log " FAILED: $pkg" "Red"
$failed += $pkg
}
} catch {
Write-Log " FAILED: $pkg - $($_.Exception.Message)" "Red"
$failed += $pkg
}
}
if ($failed.Count -eq 0) {
Write-Log " SUCCESS: All Python packages installed!" "Green"
return $true
} else {
Write-Log " WARNING: Some packages failed: $($failed -join ', ')" "Yellow"
# Return true if core packages succeeded
$criticalFailed = $failed | Where-Object { $_ -in @("pytesseract", "pillow", "pdf2image") }
return ($criticalFailed.Count -eq 0)
}
}
# Add directory to User PATH
function Add-ToUserPath {
param([string]$Path)
if (-not (Test-Path $Path)) {
Write-Log " WARNING: Path does not exist: $Path" "Yellow"
return
}
# Add to current session immediately
if ($env:PATH -notlike "*$Path*") {
$env:PATH = "$Path;$env:PATH"
Write-Log " Added to current session PATH: $Path" "Green"
}
# Add to User PATH permanently
try {
$userPath = [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::User)
if ($userPath -notlike "*$Path*") {
$newPath = "$userPath;$Path"
[Environment]::SetEnvironmentVariable("Path", $newPath, [EnvironmentVariableTarget]::User)
Write-Log " Added to permanent User PATH: $Path" "Green"
}
} catch {
Write-Log " WARNING: Could not update permanent PATH: $($_.Exception.Message)" "Yellow"
}
}
# Verify installations
function Verify-Installations {
Write-Log "" "White"
Write-Log "=========================================================" "Cyan"
Write-Log "Verifying Installations..." "Cyan"
Write-Log "=========================================================" "Cyan"
$allGood = $true
# Test Tesseract
try {
$version = & tesseract --version 2>&1 | Select-Object -First 1
Write-Log "[OK] Tesseract: $version" "Green"
} catch {
Write-Log "[FAIL] Tesseract not accessible" "Red"
$allGood = $false
}
# Test Poppler
try {
$version = & pdftoppm -v 2>&1 | Select-Object -First 1
Write-Log "[OK] Poppler: $version" "Green"
} catch {
Write-Log "[FAIL] Poppler not accessible" "Red"
$allGood = $false
}
# Test Python packages
$testScript = @"
import sys
packages = ['pytesseract', 'PIL', 'pdf2image', 'PyPDF2', 'docx', 'cv2', 'numpy']
failed = []
for pkg in packages:
try:
__import__(pkg)
except ImportError:
failed.append(pkg)
if failed:
print('FAILED:' + ','.join(failed))
sys.exit(1)
else:
print('OK')
sys.exit(0)
"@
try {
$result = & python -c $testScript 2>&1
if ($result -like "*OK*") {
Write-Log "[OK] All Python packages verified" "Green"
} else {
Write-Log "[FAIL] Some Python packages missing: $result" "Red"
$allGood = $false
}
} catch {
Write-Log "[FAIL] Python package verification failed" "Red"
$allGood = $false
}
return $allGood
}
# Main installation flow
try {
# Install components
$tesseractOk = Install-Tesseract
$popplerOk = Install-Poppler
$pythonOk = Install-PythonPackages
# Verify
Write-Log "" "White"
$verified = Verify-Installations
# Results
Write-Log "" "White"
Write-Log "=========================================================" "Cyan"
if ($tesseractOk -and $popplerOk -and $pythonOk -and $verified) {
Write-Log "INSTALLATION COMPLETED SUCCESSFULLY!" "Green"
Write-Log "=========================================================" "Cyan"
Write-Log "" "White"
Write-Log "All dependencies installed and verified!" "Green"
Write-Log "Installation log: $INSTALL_LOG" "Gray"
Write-Log "" "White"
Write-Log "NOTE: If PATH commands don't work immediately," "Yellow"
Write-Log " the current PowerShell session has been updated." "Yellow"
Write-Log " New sessions will use the permanent PATH." "Yellow"
Write-Log "" "White"
# Clean up temp directory
if (Test-Path $TEMP_DIR) {
Remove-Item $TEMP_DIR -Recurse -Force -ErrorAction SilentlyContinue
}
exit 0
} else {
Write-Log "INSTALLATION COMPLETED WITH ERRORS" "Red"
Write-Log "=========================================================" "Cyan"
Write-Log "" "White"
Write-Log "Some components failed to install:" "Yellow"
if (-not $tesseractOk) { Write-Log " - Tesseract OCR" "Red" }
if (-not $popplerOk) { Write-Log " - Poppler" "Red" }
if (-not $pythonOk) { Write-Log " - Python packages" "Red" }
Write-Log "" "White"
Write-Log "Check installation log: $INSTALL_LOG" "Yellow"
Write-Log "" "White"
exit 1
}
} catch {
Write-Log "" "White"
Write-Log "=========================================================" "Red"
Write-Log "FATAL ERROR DURING INSTALLATION" "Red"
Write-Log "=========================================================" "Red"
Write-Log $_.Exception.Message "Red"
Write-Log "" "White"
Write-Log "Check installation log: $INSTALL_LOG" "Yellow"
Write-Log "" "White"
exit 1
}
?? check\install_dependencies.backup copy.ps1
powershell
# Automatic Dependency Installer for OCR-Enabled Currency Distribution Backend
# This script installs Tesseract OCR, Poppler, and Python packages automatically
param(
[switch]$Force,
[switch]$Silent
)
$ErrorActionPreference = "Continue"
$ProgressPreference = "SilentlyContinue"
# Configuration
$INSTALL_DIR = "$env:LOCALAPPDATA\CurrencyDistributor"
$TESSERACT_DIR = "$INSTALL_DIR\Tesseract-OCR"
$POPPLER_DIR = "$INSTALL_DIR\poppler"
$INSTALL_LOG = "$INSTALL_DIR\install.log"
# URLs for downloads - using stable, verified versions
$TESSERACT_URL = "https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-5.4.0.20240606.exe"
$POPPLER_URL = "https://github.com/oschwartz10612/poppler-windows/releases/download/v24.08.0-0/Release-24.08.0-0.zip"
# Color output functions
function Write-Info {
param($Message)
if (-not $Silent) {
Write-Host "[INFO] $Message" -ForegroundColor Cyan
}
Add-Content -Path $INSTALL_LOG -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] $Message" -ErrorAction SilentlyContinue
}
function Write-Success {
param($Message)
if (-not $Silent) {
Write-Host "[SUCCESS] $Message" -ForegroundColor Green
}
Add-Content -Path $INSTALL_LOG -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [SUCCESS] $Message" -ErrorAction SilentlyContinue
}
function Write-Warning {
param($Message)
if (-not $Silent) {
Write-Host "[WARNING] $Message" -ForegroundColor Yellow
}
Add-Content -Path $INSTALL_LOG -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [WARNING] $Message" -ErrorAction SilentlyContinue
}
function Write-Error-Log {
param($Message)
if (-not $Silent) {
Write-Host "[ERROR] $Message" -ForegroundColor Red
}
Add-Content -Path $INSTALL_LOG -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] $Message" -ErrorAction SilentlyContinue
}
# Create installation directory
function Initialize-InstallDirectory {
if (-not (Test-Path $INSTALL_DIR)) {
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
Write-Info "Created installation directory: $INSTALL_DIR"
}
}
# Check if Tesseract is installed
function Test-TesseractInstalled {
# Check common locations
$tesseractPaths = @(
"$TESSERACT_DIR\tesseract.exe",
"C:\Program Files\Tesseract-OCR\tesseract.exe",
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
)
foreach ($path in $tesseractPaths) {
if (Test-Path $path) {
Write-Info "Tesseract found at: $path"
return $path
}
}
# Check if in PATH
try {
$null = & tesseract --version 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Info "Tesseract found in PATH"
return "tesseract"
}
} catch {}
return $null
}
# Check if Poppler is installed
function Test-PopplerInstalled {
$popplerPaths = @(
"$POPPLER_DIR\Library\bin\pdftoppm.exe",
"C:\Program Files\poppler\Library\bin\pdftoppm.exe"
)
foreach ($path in $popplerPaths) {
if (Test-Path $path) {
Write-Info "Poppler found at: $path"
return $path
}
}
# Check if in PATH
try {
$null = & pdftoppm -v 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Info "Poppler found in PATH"
return "pdftoppm"
}
} catch {}
return $null
}
# Download file with progress
function Download-File {
param(
[string]$Url,
[string]$OutputPath
)
try {
Write-Info "Downloading from: $Url"
Write-Info "Saving to: $OutputPath"
# Create directory if it doesn't exist
$outputDir = Split-Path $OutputPath -Parent
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
# Use Invoke-WebRequest with proper headers to avoid 403 errors
$ProgressPreference = 'Continue'
try {
# Create headers to mimic a browser request
$headers = @{
'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
'Accept' = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
'Accept-Language' = 'en-US,en;q=0.5'
'Accept-Encoding' = 'gzip, deflate, br'
'DNT' = '1'
'Connection' = 'keep-alive'
'Upgrade-Insecure-Requests' = '1'
}
Write-Info "Starting download (this may take a few minutes)..."
Invoke-WebRequest -Uri $Url -OutFile $OutputPath -Headers $headers -TimeoutSec 600 -MaximumRedirection 5
if (Test-Path $OutputPath) {
$fileSize = (Get-Item $OutputPath).Length / 1MB
Write-Success "Downloaded successfully ($('{0:N2}' -f $fileSize) MB)"
return $true
} else {
Write-Error-Log "Download completed but file not found at: $OutputPath"
return $false
}
} catch {
Write-Warning "Invoke-WebRequest failed: $($_.Exception.Message)"
Write-Info "Trying alternative download method..."
# Fallback to WebClient with headers
try {
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
$webClient.Headers.Add("Accept", "*/*")
$webClient.DownloadFile($Url, $OutputPath)
if (Test-Path $OutputPath) {
$fileSize = (Get-Item $OutputPath).Length / 1MB
Write-Success "Downloaded successfully ($('{0:N2}' -f $fileSize) MB)"
return $true
} else {
Write-Error-Log "Download completed but file not found"
return $false
}
} catch {
Write-Error-Log "Alternative download method also failed: $($_.Exception.Message)"
throw
}
}
} catch {
Write-Error-Log "Failed to download from $Url"
Write-Error-Log "Error: $($_.Exception.Message)"
# Provide helpful error message based on error type
if ($_.Exception.Message -match "403|Forbidden") {
Write-Error-Log "Access forbidden - the server is blocking automated downloads"
Write-Info "Please download manually from: $Url"
Write-Info "Save to: $OutputPath"
} elseif ($_.Exception.Message -match "404|Not Found") {
Write-Error-Log "File not found - URL may be incorrect or file no longer available"
} elseif ($_.Exception.Message -match "timeout|timed out") {
Write-Error-Log "Download timed out - please check your internet connection"
}
return $false
} finally {
$ProgressPreference = 'SilentlyContinue'
}
}
# Install Tesseract
function Install-Tesseract {
Write-Info "Installing Tesseract OCR..."
$installerPath = "$env:TEMP\tesseract-installer.exe"
# Remove old installer if exists
if (Test-Path $installerPath) {
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
}
# Download Tesseract installer
Write-Info "Downloading Tesseract installer (this may take a few minutes)..."
if (-not (Download-File -Url $TESSERACT_URL -OutputPath $installerPath)) {
Write-Error-Log "Failed to download Tesseract installer"
return $false
}
# Verify download
if (-not (Test-Path $installerPath)) {
Write-Error-Log "Installer file not found after download: $installerPath"
return $false
}
$installerSize = (Get-Item $installerPath).Length / 1MB
Write-Info "Installer downloaded: $('{0:N2}' -f $installerSize) MB"
# Install silently
try {
Write-Info "Running Tesseract installer (silent mode)..."
Write-Info "Installation directory: $TESSERACT_DIR"
$installArgs = @(
"/S", # Silent install
"/D=$TESSERACT_DIR" # Installation directory
)
$process = Start-Process -FilePath $installerPath -ArgumentList $installArgs -Wait -PassThru -NoNewWindow
Write-Info "Installer exited with code: $($process.ExitCode)"
# Check if installation succeeded
$tesseractExe = "$TESSERACT_DIR\tesseract.exe"
if (Test-Path $tesseractExe) {
Write-Success "Tesseract installed successfully at: $TESSERACT_DIR"
# Clean up installer
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
# Add to PATH
Add-ToPath -Path $TESSERACT_DIR
return $true
} else {
Write-Error-Log "Tesseract installation completed but tesseract.exe not found at: $tesseractExe"
Write-Error-Log "Installation may have failed or used a different directory"
return $false
}
} catch {
Write-Error-Log "Failed to install Tesseract: $_"
Write-Error-Log "Exception details: $($_.Exception.Message)"
return $false
}
}
# Install Poppler
function Install-Poppler {
Write-Info "Installing Poppler..."
$zipPath = "$env:TEMP\poppler.zip"
# Remove old zip if exists
if (Test-Path $zipPath) {
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
}
# Download Poppler
Write-Info "Downloading Poppler (this may take a few minutes)..."
if (-not (Download-File -Url $POPPLER_URL -OutputPath $zipPath)) {
Write-Error-Log "Failed to download Poppler"
return $false
}
# Verify download
if (-not (Test-Path $zipPath)) {
Write-Error-Log "Poppler zip file not found after download: $zipPath"
return $false
}
$zipSize = (Get-Item $zipPath).Length / 1MB
Write-Info "Poppler downloaded: $('{0:N2}' -f $zipSize) MB"
# Extract
try {
Write-Info "Extracting Poppler to: $POPPLER_DIR"
# Create Poppler directory
if (Test-Path $POPPLER_DIR) {
Write-Info "Removing existing Poppler installation..."
Remove-Item $POPPLER_DIR -Recurse -Force -ErrorAction Stop
}
New-Item -ItemType Directory -Path $POPPLER_DIR -Force | Out-Null
# Extract using built-in .NET
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $POPPLER_DIR)
Write-Success "Poppler extracted successfully"
# Clean up
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
# Add to PATH - search for bin directory dynamically
$binDirs = Get-ChildItem -Path $POPPLER_DIR -Recurse -Directory -Filter "bin" -ErrorAction SilentlyContinue
$foundBin = $false
foreach ($binDir in $binDirs) {
if (Test-Path "$($binDir.FullName)\pdftoppm.exe") {
Add-ToPath -Path $binDir.FullName
Write-Success "Found Poppler bin at: $($binDir.FullName)"
$foundBin = $true
break
}
}
if (-not $foundBin) {
Write-Warning "Could not find Poppler bin directory with pdftoppm.exe"
Write-Warning "You may need to manually add Poppler to PATH"
return $false
}
return $true
} catch {
Write-Error-Log "Failed to extract Poppler: $_"
Write-Error-Log "Exception details: $($_.Exception.Message)"
return $false
}
}
# Add directory to PATH
function Add-ToPath {
param([string]$Path)
if (-not (Test-Path $Path)) {
Write-Warning "Path does not exist: $Path"
return
}
# Add to current session
$env:PATH = "$Path;$env:PATH"
# Add to user PATH permanently
try {
$currentPath = [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::User)
if ($currentPath -notlike "*$Path*") {
[Environment]::SetEnvironmentVariable(
"Path",
"$currentPath;$Path",
[EnvironmentVariableTarget]::User
)
Write-Success "Added to PATH: $Path"
}
} catch {
Write-Warning "Could not add to permanent PATH: $_"
}
}
# Install Python packages
function Install-PythonPackages {
Write-Info "Installing Python packages..."
$packages = @(
"pytesseract>=0.3.10",
"pillow>=10.0.0",
"pdf2image>=1.16.0",
"PyPDF2>=3.0.0",
"python-docx>=1.1.0",
"opencv-python>=4.8.0",
"numpy>=1.24.0"
)
try {
# Check if Python is available
try {
$pythonVersion = & python --version 2>&1
Write-Info "Python found: $pythonVersion"
} catch {
Write-Error-Log "Python is not installed or not in PATH"
Write-Error-Log "Please install Python 3.8+ from https://www.python.org/"
return $false
}
# Check if pip is available
try {
$pipVersion = & python -m pip --version 2>&1
Write-Info "pip found: $pipVersion"
} catch {
Write-Error-Log "Python pip is not available"
Write-Error-Log "Please ensure pip is installed with Python"
return $false
}
Write-Info "Installing packages: $($packages -join ', ')"
Write-Info "This may take several minutes..."
# Upgrade pip first
Write-Info "Upgrading pip..."
& python -m pip install --upgrade pip --quiet 2>&1 | Out-Null
# Install packages one by one for better error handling
$failed = @()
$success = @()
foreach ($package in $packages) {
$packageName = $package -replace '>=.*', ''
Write-Info "Installing $packageName..."
try {
# Use --only-binary for packages that might need compilation
if ($package -match "numpy|opencv-python") {
$output = & python -m pip install --only-binary :all: $package 2>&1
} else {
$output = & python -m pip install $package 2>&1
}
if ($LASTEXITCODE -eq 0) {
Write-Success "✓ Installed $packageName"
$success += $packageName
} else {
Write-Warning "✗ Failed to install $packageName"
Write-Warning "Output: $($output | Out-String)"
$failed += $packageName
}
} catch {
Write-Warning "✗ Exception installing $packageName : $_"
$failed += $packageName
}
}
Write-Info ""
Write-Info "Installation summary:"
Write-Success "Successful: $($success.Count)/$($packages.Count) packages"
if ($success.Count -gt 0) {
$success | ForEach-Object { Write-Success " ✓ $_" }
}
if ($failed.Count -gt 0) {
Write-Warning "Failed: $($failed.Count) packages"
$failed | ForEach-Object { Write-Warning " ✗ $_" }
Write-Warning "Some packages failed, but OCR may still work"
}
# Return success if at least core packages are installed
$corePackages = @('pytesseract', 'pillow', 'pdf2image')
$coreInstalled = $true
foreach ($core in $corePackages) {
if ($failed -contains $core) {
$coreInstalled = $false
break
}
}
if ($coreInstalled) {
Write-Success "Core OCR packages installed successfully"
return $true
} else {
Write-Error-Log "Core OCR packages failed to install"
return $false
}
} catch {
Write-Error-Log "Failed to install Python packages: $_"
Write-Error-Log "Exception details: $($_.Exception.Message)"
return $false
}
}
# Verify installations
function Test-AllDependencies {
Write-Info "Verifying installations..."
$allGood = $true
# Test Tesseract
try {
$tesseractPath = Test-TesseractInstalled
if ($tesseractPath) {
if ($tesseractPath -eq "tesseract") {
$version = & tesseract --version 2>&1 | Select-Object -First 1
} else {
$version = & $tesseractPath --version 2>&1 | Select-Object -First 1
}
Write-Success "Tesseract: $version"
} else {
Write-Error-Log "Tesseract not found"
$allGood = $false
}
} catch {
Write-Error-Log "Tesseract verification failed: $_"
$allGood = $false
}
# Test Poppler
try {
$popplerPath = Test-PopplerInstalled
if ($popplerPath) {
if ($popplerPath -eq "pdftoppm") {
$version = & pdftoppm -v 2>&1 | Select-Object -First 1
} else {
$version = & $popplerPath -v 2>&1 | Select-Object -First 1
}
Write-Success "Poppler: $version"
} else {
Write-Error-Log "Poppler not found"
$allGood = $false
}
} catch {
Write-Error-Log "Poppler verification failed: $_"
$allGood = $false
}
# Test Python packages
try {
$testScript = @"
import sys
packages = ['pytesseract', 'PIL', 'pdf2image', 'PyPDF2', 'docx', 'cv2', 'numpy']
missing = []
for pkg in packages:
try:
__import__(pkg)
print(f'✓ {pkg}')
except ImportError:
missing.append(pkg)
print(f'✗ {pkg}')
sys.exit(1)
"@
$result = & python -c $testScript 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Success "All Python packages verified"
$result | ForEach-Object { Write-Info $_ }
} else {
Write-Error-Log "Some Python packages are missing"
$result | ForEach-Object { Write-Warning $_ }
$allGood = $false
}
} catch {
Write-Error-Log "Python package verification failed: $_"
$allGood = $false
}
return $allGood
}
# Main installation flow
function Start-Installation {
Write-Info "==================================================="
Write-Info "Currency Distributor - Dependency Installer"
Write-Info "==================================================="
Write-Info ""
Initialize-InstallDirectory
$needsInstall = $false
# Check Tesseract
if ($Force -or -not (Test-TesseractInstalled)) {
Write-Info "Tesseract OCR not found, will install..."
$needsInstall = $true
if (-not (Install-Tesseract)) {
Write-Error-Log "Failed to install Tesseract"
return $false
}
} else {
Write-Success "Tesseract OCR already installed"
}
# Check Poppler
if ($Force -or -not (Test-PopplerInstalled)) {
Write-Info "Poppler not found, will install..."
$needsInstall = $true
if (-not (Install-Poppler)) {
Write-Error-Log "Failed to install Poppler"
return $false
}
} else {
Write-Success "Poppler already installed"
}
# Install Python packages
Write-Info ""
if (-not (Install-PythonPackages)) {
Write-Warning "Some Python packages failed to install, but continuing..."
}
# Verify everything
Write-Info ""
Write-Info "==================================================="
Write-Info "Verification"
Write-Info "==================================================="
if (Test-AllDependencies) {
Write-Info ""
Write-Success "==================================================="
Write-Success "All dependencies installed and verified!"
Write-Success "==================================================="
Write-Info ""
Write-Info "Installation log: $INSTALL_LOG"
Write-Info ""
if ($needsInstall) {
Write-Warning "IMPORTANT: Please restart your terminal/PowerShell"
Write-Warning "to ensure PATH changes take effect."
}
return $true
} else {
Write-Info ""
Write-Error-Log "==================================================="
Write-Error-Log "Some dependencies failed verification"
Write-Error-Log "==================================================="
Write-Info ""
Write-Info "Check installation log: $INSTALL_LOG"
return $false
}
}
# Run installation
$success = Start-Installation
# Exit with appropriate code
if ($success) {
exit 0
} else {
exit 1
}
?? check\install_dependencies.backup.ps1
powershell
# Automatic Dependency Installer for OCR-Enabled Currency Distribution Backend
# This script installs Tesseract OCR, Poppler, and Python packages automatically
param(
[switch]$Force,
[switch]$Silent
)
$ErrorActionPreference = "Continue"
$ProgressPreference = "SilentlyContinue"
# Configuration
$INSTALL_DIR = "$env:LOCALAPPDATA\CurrencyDistributor"
$TESSERACT_DIR = "$INSTALL_DIR\Tesseract-OCR"
$POPPLER_DIR = "$INSTALL_DIR\poppler"
$INSTALL_LOG = "$INSTALL_DIR\install.log"
# URLs for downloads - using stable, verified versions
$TESSERACT_URL = "https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-5.4.0.20240606.exe"
$POPPLER_URL = "https://github.com/oschwartz10612/poppler-windows/releases/download/v24.08.0-0/Release-24.08.0-0.zip"
# Color output functions
function Write-Info {
param($Message)
if (-not $Silent) {
Write-Host "[INFO] $Message" -ForegroundColor Cyan
}
Add-Content -Path $INSTALL_LOG -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [INFO] $Message" -ErrorAction SilentlyContinue
}
function Write-Success {
param($Message)
if (-not $Silent) {
Write-Host "[SUCCESS] $Message" -ForegroundColor Green
}
Add-Content -Path $INSTALL_LOG -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [SUCCESS] $Message" -ErrorAction SilentlyContinue
}
function Write-Warning {
param($Message)
if (-not $Silent) {
Write-Host "[WARNING] $Message" -ForegroundColor Yellow
}
Add-Content -Path $INSTALL_LOG -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [WARNING] $Message" -ErrorAction SilentlyContinue
}
function Write-Error-Log {
param($Message)
if (-not $Silent) {
Write-Host "[ERROR] $Message" -ForegroundColor Red
}
Add-Content -Path $INSTALL_LOG -Value "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [ERROR] $Message" -ErrorAction SilentlyContinue
}
# Create installation directory
function Initialize-InstallDirectory {
if (-not (Test-Path $INSTALL_DIR)) {
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
Write-Info "Created installation directory: $INSTALL_DIR"
}
}
# Check if Tesseract is installed
function Test-TesseractInstalled {
# Check common locations
$tesseractPaths = @(
"$TESSERACT_DIR\tesseract.exe",
"C:\Program Files\Tesseract-OCR\tesseract.exe",
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
)
foreach ($path in $tesseractPaths) {
if (Test-Path $path) {
Write-Info "Tesseract found at: $path"
return $path
}
}
# Check if in PATH
try {
$null = & tesseract --version 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Info "Tesseract found in PATH"
return "tesseract"
}
} catch {}
return $null
}
# Check if Poppler is installed
function Test-PopplerInstalled {
$popplerPaths = @(
"$POPPLER_DIR\Library\bin\pdftoppm.exe",
"C:\Program Files\poppler\Library\bin\pdftoppm.exe"
)
foreach ($path in $popplerPaths) {
if (Test-Path $path) {
Write-Info "Poppler found at: $path"
return $path
}
}
# Check if in PATH
try {
$null = & pdftoppm -v 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Info "Poppler found in PATH"
return "pdftoppm"
}
} catch {}
return $null
}
# Download file with progress
function Download-File {
param(
[string]$Url,
[string]$OutputPath
)
try {
Write-Info "Downloading from: $Url"
Write-Info "Saving to: $OutputPath"
# Create directory if it doesn't exist
$outputDir = Split-Path $OutputPath -Parent
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
# Use Invoke-WebRequest with proper headers to avoid 403 errors
$ProgressPreference = 'Continue'
try {
# Create headers to mimic a browser request
$headers = @{
'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
'Accept' = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8'
'Accept-Language' = 'en-US,en;q=0.5'
'Accept-Encoding' = 'gzip, deflate, br'
'DNT' = '1'
'Connection' = 'keep-alive'
'Upgrade-Insecure-Requests' = '1'
}
Write-Info "Starting download (this may take a few minutes)..."
Invoke-WebRequest -Uri $Url -OutFile $OutputPath -Headers $headers -TimeoutSec 600 -MaximumRedirection 5
if (Test-Path $OutputPath) {
$fileSize = (Get-Item $OutputPath).Length / 1MB
Write-Success "Downloaded successfully ($('{0:N2}' -f $fileSize) MB)"
return $true
} else {
Write-Error-Log "Download completed but file not found at: $OutputPath"
return $false
}
} catch {
Write-Warning "Invoke-WebRequest failed: $($_.Exception.Message)"
Write-Info "Trying alternative download method..."
# Fallback to WebClient with headers
try {
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
$webClient.Headers.Add("Accept", "*/*")
$webClient.DownloadFile($Url, $OutputPath)
if (Test-Path $OutputPath) {
$fileSize = (Get-Item $OutputPath).Length / 1MB
Write-Success "Downloaded successfully ($('{0:N2}' -f $fileSize) MB)"
return $true
} else {
Write-Error-Log "Download completed but file not found"
return $false
}
} catch {
Write-Error-Log "Alternative download method also failed: $($_.Exception.Message)"
throw
}
}
} catch {
Write-Error-Log "Failed to download from $Url"
Write-Error-Log "Error: $($_.Exception.Message)"
# Provide helpful error message based on error type
if ($_.Exception.Message -match "403|Forbidden") {
Write-Error-Log "Access forbidden - the server is blocking automated downloads"
Write-Info "Please download manually from: $Url"
Write-Info "Save to: $OutputPath"
} elseif ($_.Exception.Message -match "404|Not Found") {
Write-Error-Log "File not found - URL may be incorrect or file no longer available"
} elseif ($_.Exception.Message -match "timeout|timed out") {
Write-Error-Log "Download timed out - please check your internet connection"
}
return $false
} finally {
$ProgressPreference = 'SilentlyContinue'
}
}
# Install Tesseract
function Install-Tesseract {
Write-Info "Installing Tesseract OCR..."
$installerPath = "$env:TEMP\tesseract-installer.exe"
# Remove old installer if exists
if (Test-Path $installerPath) {
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
}
# Download Tesseract installer
Write-Info "Downloading Tesseract installer (this may take a few minutes)..."
if (-not (Download-File -Url $TESSERACT_URL -OutputPath $installerPath)) {
Write-Error-Log "Failed to download Tesseract installer"
return $false
}
# Verify download
if (-not (Test-Path $installerPath)) {
Write-Error-Log "Installer file not found after download: $installerPath"
return $false
}
$installerSize = (Get-Item $installerPath).Length / 1MB
Write-Info "Installer downloaded: $('{0:N2}' -f $installerSize) MB"
# Install silently
try {
Write-Info "Running Tesseract installer (silent mode)..."
Write-Info "Installation directory: $TESSERACT_DIR"
$installArgs = @(
"/S", # Silent install
"/D=$TESSERACT_DIR" # Installation directory
)
$process = Start-Process -FilePath $installerPath -ArgumentList $installArgs -Wait -PassThru -NoNewWindow
Write-Info "Installer exited with code: $($process.ExitCode)"
# Check if installation succeeded
$tesseractExe = "$TESSERACT_DIR\tesseract.exe"
if (Test-Path $tesseractExe) {
Write-Success "Tesseract installed successfully at: $TESSERACT_DIR"
# Clean up installer
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
# Add to PATH
Add-ToPath -Path $TESSERACT_DIR
return $true
} else {
Write-Error-Log "Tesseract installation completed but tesseract.exe not found at: $tesseractExe"
Write-Error-Log "Installation may have failed or used a different directory"
return $false
}
} catch {
Write-Error-Log "Failed to install Tesseract: $_"
Write-Error-Log "Exception details: $($_.Exception.Message)"
return $false
}
}
# Install Poppler
function Install-Poppler {
Write-Info "Installing Poppler..."
$zipPath = "$env:TEMP\poppler.zip"
# Remove old zip if exists
if (Test-Path $zipPath) {
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
}
# Download Poppler
Write-Info "Downloading Poppler (this may take a few minutes)..."
if (-not (Download-File -Url $POPPLER_URL -OutputPath $zipPath)) {
Write-Error-Log "Failed to download Poppler"
return $false
}
# Verify download
if (-not (Test-Path $zipPath)) {
Write-Error-Log "Poppler zip file not found after download: $zipPath"
return $false
}
$zipSize = (Get-Item $zipPath).Length / 1MB
Write-Info "Poppler downloaded: $('{0:N2}' -f $zipSize) MB"
# Extract
try {
Write-Info "Extracting Poppler to: $POPPLER_DIR"
# Create Poppler directory
if (Test-Path $POPPLER_DIR) {
Write-Info "Removing existing Poppler installation..."
Remove-Item $POPPLER_DIR -Recurse -Force -ErrorAction Stop
}
New-Item -ItemType Directory -Path $POPPLER_DIR -Force | Out-Null
# Extract using built-in .NET
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $POPPLER_DIR)
Write-Success "Poppler extracted successfully"
# Clean up
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
# Add to PATH - search for bin directory dynamically
$binDirs = Get-ChildItem -Path $POPPLER_DIR -Recurse -Directory -Filter "bin" -ErrorAction SilentlyContinue
$foundBin = $false
foreach ($binDir in $binDirs) {
if (Test-Path "$($binDir.FullName)\pdftoppm.exe") {
Add-ToPath -Path $binDir.FullName
Write-Success "Found Poppler bin at: $($binDir.FullName)"
$foundBin = $true
break
}
}
if (-not $foundBin) {
Write-Warning "Could not find Poppler bin directory with pdftoppm.exe"
Write-Warning "You may need to manually add Poppler to PATH"
return $false
}
return $true
} catch {
Write-Error-Log "Failed to extract Poppler: $_"
Write-Error-Log "Exception details: $($_.Exception.Message)"
return $false
}
}
# Add directory to PATH
function Add-ToPath {
param([string]$Path)
if (-not (Test-Path $Path)) {
Write-Warning "Path does not exist: $Path"
return
}
# Add to current session
$env:PATH = "$Path;$env:PATH"
# Add to user PATH permanently
try {
$currentPath = [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::User)
if ($currentPath -notlike "*$Path*") {
[Environment]::SetEnvironmentVariable(
"Path",
"$currentPath;$Path",
[EnvironmentVariableTarget]::User
)
Write-Success "Added to PATH: $Path"
}
} catch {
Write-Warning "Could not add to permanent PATH: $_"
}
}
# Install Python packages
function Install-PythonPackages {
Write-Info "Installing Python packages..."
$packages = @(
"pytesseract>=0.3.10",
"pillow>=10.0.0",
"pdf2image>=1.16.0",
"PyPDF2>=3.0.0",
"python-docx>=1.1.0",
"opencv-python>=4.8.0",
"numpy>=1.24.0"
)
try {
# Check if Python is available
try {
$pythonVersion = & python --version 2>&1
Write-Info "Python found: $pythonVersion"
} catch {
Write-Error-Log "Python is not installed or not in PATH"
Write-Error-Log "Please install Python 3.8+ from https://www.python.org/"
return $false
}
# Check if pip is available
try {
$pipVersion = & python -m pip --version 2>&1
Write-Info "pip found: $pipVersion"
} catch {
Write-Error-Log "Python pip is not available"
Write-Error-Log "Please ensure pip is installed with Python"
return $false
}
Write-Info "Installing packages: $($packages -join ', ')"
Write-Info "This may take several minutes..."
# Upgrade pip first
Write-Info "Upgrading pip..."
& python -m pip install --upgrade pip --quiet 2>&1 | Out-Null
# Install packages one by one for better error handling
$failed = @()
$success = @()
foreach ($package in $packages) {
$packageName = $package -replace '>=.*', ''
Write-Info "Installing $packageName..."
try {
# Use --only-binary for packages that might need compilation
if ($package -match "numpy|opencv-python") {
$output = & python -m pip install --only-binary :all: $package 2>&1
} else {
$output = & python -m pip install $package 2>&1
}
if ($LASTEXITCODE -eq 0) {
Write-Success "✓ Installed $packageName"
$success += $packageName
} else {
Write-Warning "✗ Failed to install $packageName"
Write-Warning "Output: $($output | Out-String)"
$failed += $packageName
}
} catch {
Write-Warning "✗ Exception installing $packageName : $_"
$failed += $packageName
}
}
Write-Info ""
Write-Info "Installation summary:"
Write-Success "Successful: $($success.Count)/$($packages.Count) packages"
if ($success.Count -gt 0) {
$success | ForEach-Object { Write-Success " ✓ $_" }
}
if ($failed.Count -gt 0) {
Write-Warning "Failed: $($failed.Count) packages"
$failed | ForEach-Object { Write-Warning " ✗ $_" }
Write-Warning "Some packages failed, but OCR may still work"
}
# Return success if at least core packages are installed
$corePackages = @('pytesseract', 'pillow', 'pdf2image')
$coreInstalled = $true
foreach ($core in $corePackages) {
if ($failed -contains $core) {
$coreInstalled = $false
break
}
}
if ($coreInstalled) {
Write-Success "Core OCR packages installed successfully"
return $true
} else {
Write-Error-Log "Core OCR packages failed to install"
return $false
}
} catch {
Write-Error-Log "Failed to install Python packages: $_"
Write-Error-Log "Exception details: $($_.Exception.Message)"
return $false
}
}
# Verify installations
function Test-AllDependencies {
Write-Info "Verifying installations..."
$allGood = $true
# Test Tesseract
try {
$tesseractPath = Test-TesseractInstalled
if ($tesseractPath) {
if ($tesseractPath -eq "tesseract") {
$version = & tesseract --version 2>&1 | Select-Object -First 1
} else {
$version = & $tesseractPath --version 2>&1 | Select-Object -First 1
}
Write-Success "Tesseract: $version"
} else {
Write-Error-Log "Tesseract not found"
$allGood = $false
}
} catch {
Write-Error-Log "Tesseract verification failed: $_"
$allGood = $false
}
# Test Poppler
try {
$popplerPath = Test-PopplerInstalled
if ($popplerPath) {
if ($popplerPath -eq "pdftoppm") {
$version = & pdftoppm -v 2>&1 | Select-Object -First 1
} else {
$version = & $popplerPath -v 2>&1 | Select-Object -First 1
}
Write-Success "Poppler: $version"
} else {
Write-Error-Log "Poppler not found"
$allGood = $false
}
} catch {
Write-Error-Log "Poppler verification failed: $_"
$allGood = $false
}
# Test Python packages
try {
$testScript = @"
import sys
packages = ['pytesseract', 'PIL', 'pdf2image', 'PyPDF2', 'docx', 'cv2', 'numpy']
missing = []
for pkg in packages:
try:
__import__(pkg)
print(f'✓ {pkg}')
except ImportError:
missing.append(pkg)
print(f'✗ {pkg}')
sys.exit(1)
"@
$result = & python -c $testScript 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Success "All Python packages verified"
$result | ForEach-Object { Write-Info $_ }
} else {
Write-Error-Log "Some Python packages are missing"
$result | ForEach-Object { Write-Warning $_ }
$allGood = $false
}
} catch {
Write-Error-Log "Python package verification failed: $_"
$allGood = $false
}
return $allGood
}
# Main installation flow
function Start-Installation {
Write-Info "==================================================="
Write-Info "Currency Distributor - Dependency Installer"
Write-Info "==================================================="
Write-Info ""
Initialize-InstallDirectory
$needsInstall = $false
# Check Tesseract
if ($Force -or -not (Test-TesseractInstalled)) {
Write-Info "Tesseract OCR not found, will install..."
$needsInstall = $true
if (-not (Install-Tesseract)) {
Write-Error-Log "Failed to install Tesseract"
return $false
}
} else {
Write-Success "Tesseract OCR already installed"
}
# Check Poppler
if ($Force -or -not (Test-PopplerInstalled)) {
Write-Info "Poppler not found, will install..."
$needsInstall = $true
if (-not (Install-Poppler)) {
Write-Error-Log "Failed to install Poppler"
return $false
}
} else {
Write-Success "Poppler already installed"
}
# Install Python packages
Write-Info ""
if (-not (Install-PythonPackages)) {
Write-Warning "Some Python packages failed to install, but continuing..."
}
# Verify everything
Write-Info ""
Write-Info "==================================================="
Write-Info "Verification"
Write-Info "==================================================="
if (Test-AllDependencies) {
Write-Info ""
Write-Success "==================================================="
Write-Success "All dependencies installed and verified!"
Write-Success "==================================================="
Write-Info ""
Write-Info "Installation log: $INSTALL_LOG"
Write-Info ""
if ($needsInstall) {
Write-Warning "IMPORTANT: Please restart your terminal/PowerShell"
Write-Warning "to ensure PATH changes take effect."
}
return $true
} else {
Write-Info ""
Write-Error-Log "==================================================="
Write-Error-Log "Some dependencies failed verification"
Write-Error-Log "==================================================="
Write-Info ""
Write-Info "Check installation log: $INSTALL_LOG"
return $false
}
}
# Run installation
$success = Start-Installation
# Exit with appropriate code
if ($success) {
exit 0
} else {
exit 1
}
?? check\install_dependencies.ps1
powershell
# Fully Automatic Dependency Installer - Currency Distributor Backend
# This script downloads and installs ALL dependencies without user intervention
param(
[switch]$Force
)
$ErrorActionPreference = "Continue"
$ProgressPreference = "SilentlyContinue"
# Configuration
$INSTALL_DIR = "$env:LOCALAPPDATA\CurrencyDistributor"
$TESSERACT_DIR = "$INSTALL_DIR\Tesseract-OCR"
$POPPLER_DIR = "$INSTALL_DIR\poppler"
$INSTALL_LOG = "$INSTALL_DIR\install.log"
$TEMP_DIR = "$env:TEMP\CurrencyDistributor"
# Create directories
New-Item -ItemType Directory -Path $INSTALL_DIR -Force | Out-Null
New-Item -ItemType Directory -Path $TEMP_DIR -Force | Out-Null
# Logging function
function Write-Log {
param($Message, $Color = "White")
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "[$timestamp] $Message"
Write-Host $logMessage -ForegroundColor $Color
Add-Content -Path $INSTALL_LOG -Value $logMessage -ErrorAction SilentlyContinue
}
Write-Log "=========================================================" "Cyan"
Write-Log "Automatic Dependency Installer - Starting..." "Cyan"
Write-Log "=========================================================" "Cyan"
# Advanced download function with multiple fallback methods
function Download-FileAdvanced {
param(
[string]$Url,
[string]$OutputPath,
[string]$Description
)
Write-Log "Downloading $Description..." "Yellow"
Write-Log "URL: $Url" "Gray"
# Ensure output directory exists
$outputDir = Split-Path $OutputPath -Parent
if (-not (Test-Path $outputDir)) {
New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
}
# Remove existing file
if (Test-Path $OutputPath) {
Remove-Item $OutputPath -Force -ErrorAction SilentlyContinue
}
# Method 1: Try .NET WebClient (fastest)
try {
Write-Log " Attempting .NET WebClient download..." "Gray"
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
$webClient.Proxy = [System.Net.WebRequest]::DefaultWebProxy
$webClient.Proxy.Credentials = [System.Net.CredentialCache]::DefaultNetworkCredentials
# Synchronous download with timeout handling
$downloadTask = $webClient.DownloadFileTaskAsync($Url, $OutputPath)
$timeoutTask = [System.Threading.Tasks.Task]::Delay(600000) # 10 minute timeout
$completedTask = [System.Threading.Tasks.Task]::WaitAny(@($downloadTask, $timeoutTask))
if ($completedTask -eq 0 -and (Test-Path $OutputPath)) {
$size = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
Write-Log " SUCCESS: Downloaded $size MB via WebClient" "Green"
$webClient.Dispose()
return $true
} else {
Write-Log " WebClient timed out or failed" "Yellow"
$webClient.Dispose()
}
} catch {
Write-Log " WebClient failed: $($_.Exception.Message)" "Yellow"
}
# Method 2: BITS Transfer (Windows Background Intelligent Transfer)
try {
Write-Log " Attempting BITS Transfer..." "Gray"
Import-Module BitsTransfer -ErrorAction Stop
Start-BitsTransfer `
-Source $Url `
-Destination $OutputPath `
-Priority High `
-RetryInterval 60 `
-RetryTimeout 300 `
-ErrorAction Stop
if (Test-Path $OutputPath) {
$size = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
Write-Log " SUCCESS: Downloaded $size MB via BITS" "Green"
return $true
}
} catch {
Write-Log " BITS Transfer failed: $($_.Exception.Message)" "Yellow"
}
# Method 3: Invoke-WebRequest with custom headers
try {
Write-Log " Attempting Invoke-WebRequest..." "Gray"
$headers = @{
'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
'Accept' = '*/*'
'Accept-Encoding' = 'gzip, deflate, br'
'Connection' = 'keep-alive'
}
Invoke-WebRequest `
-Uri $Url `
-OutFile $OutputPath `
-Headers $headers `
-UseBasicParsing `
-TimeoutSec 600 `
-MaximumRedirection 10 `
-ErrorAction Stop
if (Test-Path $OutputPath) {
$size = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
Write-Log " SUCCESS: Downloaded $size MB via Invoke-WebRequest" "Green"
return $true
}
} catch {
Write-Log " Invoke-WebRequest failed: $($_.Exception.Message)" "Yellow"
}
# Method 4: Alternative URLs/Mirrors
if ($Url -like "*tesseract*") {
Write-Log " Trying alternative Tesseract source..." "Yellow"
$altUrls = @(
"https://github.com/UB-Mannheim/tesseract/releases/download/v5.4.0.20240606/tesseract-ocr-w64-setup-5.4.0.20240606.exe",
"https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-5.3.3.20231005.exe"
)
foreach ($altUrl in $altUrls) {
try {
Write-Log " Trying: $altUrl" "Gray"
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("User-Agent", "Mozilla/5.0")
$webClient.DownloadFile($altUrl, $OutputPath)
$webClient.Dispose()
if (Test-Path $OutputPath) {
$size = [math]::Round((Get-Item $OutputPath).Length / 1MB, 2)
if ($size -gt 10) { # Valid installer should be > 10MB
Write-Log " SUCCESS: Downloaded from alternative source ($size MB)" "Green"
return $true
}
}
} catch {
Write-Log " Alternative URL failed: $($_.Exception.Message)" "Yellow"
}
}
}
Write-Log " FAILED: All download methods exhausted" "Red"
return $false
}
# Install Tesseract OCR
function Install-Tesseract {
Write-Log "" "White"
Write-Log "[1/3] Installing Tesseract OCR..." "Cyan"
# Check if already installed
$tesseractExe = "$TESSERACT_DIR\tesseract.exe"
if ((Test-Path $tesseractExe) -and -not $Force) {
Write-Log " Tesseract already installed at: $TESSERACT_DIR" "Green"
Add-ToUserPath -Path $TESSERACT_DIR
return $true
}
# Check system-wide installation
$systemPaths = @(
"C:\Program Files\Tesseract-OCR\tesseract.exe",
"C:\Program Files (x86)\Tesseract-OCR\tesseract.exe"
)
foreach ($path in $systemPaths) {
if (Test-Path $path) {
Write-Log " Found system Tesseract at: $path" "Green"
$script:TESSERACT_DIR = Split-Path $path -Parent
Add-ToUserPath -Path $script:TESSERACT_DIR
return $true
}
}
# Download installer
$installerUrl = "https://digi.bib.uni-mannheim.de/tesseract/tesseract-ocr-w64-setup-5.4.0.20240606.exe"
$installerPath = "$TEMP_DIR\tesseract-installer.exe"
Write-Log " Downloading Tesseract installer..." "Yellow"
if (-not (Download-FileAdvanced -Url $installerUrl -OutputPath $installerPath -Description "Tesseract OCR")) {
Write-Log " ERROR: Failed to download Tesseract installer" "Red"
return $false
}
# Verify download
if (-not (Test-Path $installerPath)) {
Write-Log " ERROR: Installer file not found after download" "Red"
return $false
}
$fileSize = (Get-Item $installerPath).Length
if ($fileSize -lt 10MB) {
Write-Log " ERROR: Downloaded file too small ($fileSize bytes), likely corrupted" "Red"
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
return $false
}
# Install silently
try {
Write-Log " Installing Tesseract (this may take 1-2 minutes)..." "Yellow"
Write-Log " Target directory: $TESSERACT_DIR" "Gray"
# Create installation directory
New-Item -ItemType Directory -Path $TESSERACT_DIR -Force | Out-Null
# Run installer using Start-Process with proper flags
$arguments = "/VERYSILENT /NORESTART /DIR=`"$TESSERACT_DIR`""
Write-Log " Executing: $installerPath $arguments" "Gray"
$process = Start-Process -FilePath $installerPath -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden
$exitCode = $process.ExitCode
Write-Log " Installer exit code: $exitCode" "Gray"
# Wait a bit for filesystem to settle
Start-Sleep -Seconds 3
# Verify installation
if (Test-Path "$TESSERACT_DIR\tesseract.exe") {
Write-Log " SUCCESS: Tesseract installed successfully!" "Green"
# Clean up installer
Remove-Item $installerPath -Force -ErrorAction SilentlyContinue
# Add to PATH
Add-ToUserPath -Path $TESSERACT_DIR
return $true
} else {
Write-Log " ERROR: Installation completed but tesseract.exe not found" "Red"
Write-Log " Checked: $TESSERACT_DIR\tesseract.exe" "Red"
# List what's in the directory for debugging
if (Test-Path $TESSERACT_DIR) {
Write-Log " Directory contents:" "Gray"
Get-ChildItem $TESSERACT_DIR -Recurse -File | Select-Object -First 10 | ForEach-Object {
Write-Log " $($_.FullName)" "Gray"
}
}
return $false
}
} catch {
Write-Log " ERROR: Installation failed - $($_.Exception.Message)" "Red"
return $false
}
}
# Install Poppler
function Install-Poppler {
Write-Log "" "White"
Write-Log "[2/3] Installing Poppler..." "Cyan"
# Function to test if Poppler is functional
function Test-PopplerFunctional {
param([string]$BinPath)
if (-not (Test-Path $BinPath)) {
return $false
}
$pdfToPpmExe = Join-Path $BinPath "pdftoppm.exe"
if (-not (Test-Path $pdfToPpmExe)) {
return $false
}
# Test if command actually works
try {
$testOutput = & $pdfToPpmExe -v 2>&1
if ($testOutput -match "pdftoppm version" -or $testOutput -match "poppler") {
return $true
}
} catch {
return $false
}
return $false
}
# Check local installation
$localBinPath = "$POPPLER_DIR\poppler-24.08.0\Library\bin"
if (Test-PopplerFunctional -BinPath $localBinPath) {
Write-Log " Poppler already installed and functional" "Green"
Add-ToUserPath -Path $localBinPath
return $true
}
# Check system installation
$systemBinPath = "C:\Program Files\poppler\Library\bin"
if (Test-PopplerFunctional -BinPath $systemBinPath) {
Write-Log " Found functional system Poppler installation" "Green"
Add-ToUserPath -Path $systemBinPath
return $true
}
# If directory exists but not functional, remove it
if (Test-Path $POPPLER_DIR) {
Write-Log " Found non-functional Poppler installation, removing..." "Yellow"
Remove-Item $POPPLER_DIR -Recurse -Force -ErrorAction SilentlyContinue
}
# Download Poppler
$popplerUrl = "https://github.com/oschwartz10612/poppler-windows/releases/download/v24.08.0-0/Release-24.08.0-0.zip"
$zipPath = "$TEMP_DIR\poppler.zip"
Write-Log " Downloading Poppler..." "Yellow"
if (-not (Download-FileAdvanced -Url $popplerUrl -OutputPath $zipPath -Description "Poppler")) {
Write-Log " ERROR: Failed to download Poppler" "Red"
return $false
}
# Verify download
if (-not (Test-Path $zipPath)) {
Write-Log " ERROR: ZIP file not found after download" "Red"
return $false
}
# Extract
try {
Write-Log " Extracting Poppler..." "Yellow"
# Remove old installation
if (Test-Path $POPPLER_DIR) {
Remove-Item $POPPLER_DIR -Recurse -Force -ErrorAction SilentlyContinue
}
New-Item -ItemType Directory -Path $POPPLER_DIR -Force | Out-Null
# Extract ZIP
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $POPPLER_DIR)
Write-Log " SUCCESS: Poppler extracted successfully!" "Green"
# Clean up ZIP
Remove-Item $zipPath -Force -ErrorAction SilentlyContinue
# Find bin directory and add to PATH
$binDirs = Get-ChildItem -Path $POPPLER_DIR -Recurse -Directory -Filter "bin" -ErrorAction SilentlyContinue
$foundWorking = $false
foreach ($binDir in $binDirs) {
$pdfToPpmPath = Join-Path $binDir.FullName "pdftoppm.exe"
if (Test-Path $pdfToPpmPath) {
Write-Log " Found Poppler bin: $($binDir.FullName)" "Green"
# Test if it's functional
if (Test-PopplerFunctional -BinPath $binDir.FullName) {
Write-Log " Verified Poppler is functional" "Green"
Add-ToUserPath -Path $binDir.FullName
$foundWorking = $true
break
} else {
Write-Log " WARNING: Found pdftoppm.exe but it's not responding correctly" "Yellow"
}
}
}
if ($foundWorking) {
return $true
} else {
Write-Log " ERROR: Poppler extracted but no functional installation found" "Red"
return $false
}
} catch {
Write-Log " ERROR: Extraction failed - $($_.Exception.Message)" "Red"
return $false
}
}
# Install Python packages
function Install-PythonPackages {
Write-Log "" "White"
Write-Log "[3/3] Installing Python Packages..." "Cyan"
# Check Python
try {
$pythonVersion = & python --version 2>&1
Write-Log " Python version: $pythonVersion" "Green"
} catch {
Write-Log " ERROR: Python not found. Please install Python 3.8+" "Red"
return $false
}
# Upgrade pip
Write-Log " Upgrading pip..." "Yellow"
& python -m pip install --upgrade pip --quiet 2>&1 | Out-Null
# Package list
$packages = @(
"pytesseract",
"pillow",
"pdf2image",
"PyPDF2",
"python-docx",
"opencv-python",
"numpy"
)
Write-Log " Installing Python packages..." "Yellow"
$failed = @()
foreach ($pkg in $packages) {
try {
Write-Log " Installing $pkg..." "Gray"
if ($pkg -match "numpy|opencv") {
$output = & python -m pip install --only-binary :all: $pkg --quiet 2>&1
} else {
$output = & python -m pip install $pkg --quiet 2>&1
}
if ($LASTEXITCODE -eq 0) {
Write-Log " SUCCESS: $pkg installed" "Green"
} else {
Write-Log " FAILED: $pkg" "Red"
$failed += $pkg
}
} catch {
Write-Log " FAILED: $pkg - $($_.Exception.Message)" "Red"
$failed += $pkg
}
}
if ($failed.Count -eq 0) {
Write-Log " SUCCESS: All Python packages installed!" "Green"
return $true
} else {
Write-Log " WARNING: Some packages failed: $($failed -join ', ')" "Yellow"
# Return true if core packages succeeded
$criticalFailed = $failed | Where-Object { $_ -in @("pytesseract", "pillow", "pdf2image") }
return ($criticalFailed.Count -eq 0)
}
}
# Add directory to User PATH
function Add-ToUserPath {
param([string]$Path)
if (-not (Test-Path $Path)) {
Write-Log " WARNING: Path does not exist: $Path" "Yellow"
return
}
# Add to current session immediately
if ($env:PATH -notlike "*$Path*") {
$env:PATH = "$Path;$env:PATH"
Write-Log " Added to current session PATH: $Path" "Green"
}
# Add to User PATH permanently
try {
$userPath = [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::User)
if ($userPath -notlike "*$Path*") {
$newPath = "$userPath;$Path"
[Environment]::SetEnvironmentVariable("Path", $newPath, [EnvironmentVariableTarget]::User)
Write-Log " Added to permanent User PATH: $Path" "Green"
}
} catch {
Write-Log " WARNING: Could not update permanent PATH: $($_.Exception.Message)" "Yellow"
}
}
# Verify installations
function Verify-Installations {
Write-Log "" "White"
Write-Log "=========================================================" "Cyan"
Write-Log "Verifying Installations..." "Cyan"
Write-Log "=========================================================" "Cyan"
$allGood = $true
# Test Tesseract
try {
$version = & tesseract --version 2>&1 | Select-Object -First 1
Write-Log "[OK] Tesseract: $version" "Green"
} catch {
Write-Log "[FAIL] Tesseract not accessible" "Red"
$allGood = $false
}
# Test Poppler
try {
$version = & pdftoppm -v 2>&1 | Select-Object -First 1
Write-Log "[OK] Poppler: $version" "Green"
} catch {
Write-Log "[FAIL] Poppler not accessible" "Red"
$allGood = $false
}
# Test Python packages
$testScript = @"
import sys
packages = ['pytesseract', 'PIL', 'pdf2image', 'PyPDF2', 'docx', 'cv2', 'numpy']
failed = []
for pkg in packages:
try:
__import__(pkg)
except ImportError:
failed.append(pkg)
if failed:
print('FAILED:' + ','.join(failed))
sys.exit(1)
else:
print('OK')
sys.exit(0)
"@
try {
$result = & python -c $testScript 2>&1
if ($result -like "*OK*") {
Write-Log "[OK] All Python packages verified" "Green"
} else {
Write-Log "[FAIL] Some Python packages missing: $result" "Red"
$allGood = $false
}
} catch {
Write-Log "[FAIL] Python package verification failed" "Red"
$allGood = $false
}
return $allGood
}
# Main installation flow
try {
# Install components
$tesseractOk = Install-Tesseract
$popplerOk = Install-Poppler
$pythonOk = Install-PythonPackages
# Verify
Write-Log "" "White"
$verified = Verify-Installations
# Results
Write-Log "" "White"
Write-Log "=========================================================" "Cyan"
if ($tesseractOk -and $popplerOk -and $pythonOk -and $verified) {
Write-Log "INSTALLATION COMPLETED SUCCESSFULLY!" "Green"
Write-Log "=========================================================" "Cyan"
Write-Log "" "White"
Write-Log "All dependencies installed and verified!" "Green"
Write-Log "Installation log: $INSTALL_LOG" "Gray"
Write-Log "" "White"
Write-Log "NOTE: If PATH commands don't work immediately," "Yellow"
Write-Log " the current PowerShell session has been updated." "Yellow"
Write-Log " New sessions will use the permanent PATH." "Yellow"
Write-Log "" "White"
# Clean up temp directory
if (Test-Path $TEMP_DIR) {
Remove-Item $TEMP_DIR -Recurse -Force -ErrorAction SilentlyContinue
}
exit 0
} else {
Write-Log "INSTALLATION COMPLETED WITH ERRORS" "Red"
Write-Log "=========================================================" "Cyan"
Write-Log "" "White"
Write-Log "Some components failed to install:" "Yellow"
if (-not $tesseractOk) { Write-Log " - Tesseract OCR" "Red" }
if (-not $popplerOk) { Write-Log " - Poppler" "Red" }
if (-not $pythonOk) { Write-Log " - Python packages" "Red" }
Write-Log "" "White"
Write-Log "Check installation log: $INSTALL_LOG" "Yellow"
Write-Log "" "White"
exit 1
}
} catch {
Write-Log "" "White"
Write-Log "=========================================================" "Red"
Write-Log "FATAL ERROR DURING INSTALLATION" "Red"
Write-Log "=========================================================" "Red"
Write-Log $_.Exception.Message "Red"
Write-Log "" "White"
Write-Log "Check installation log: $INSTALL_LOG" "Yellow"
Write-Log "" "White"
exit 1
}
?? check\START_BACKEND copy.bat
batch
@echo off
REM Currency Distributor Backend - Easy Start
REM Double-click this file to start the backend with automatic dependency installation
echo ====================================================
echo Currency Distributor Backend - Auto Start
echo ====================================================
echo.
REM Run PowerShell script with auto-install
PowerShell.exe -ExecutionPolicy Bypass -File "%~dp0start_with_auto_install.ps1"
pause
?? check\START_BACKEND.bat
batch
@echo off
REM Currency Distributor Backend - Easy Start
REM Double-click this file to start the backend with automatic dependency installation
echo ====================================================
echo Currency Distributor Backend - Auto Start
echo ====================================================
echo.
REM Run PowerShell script with auto-install
PowerShell.exe -ExecutionPolicy Bypass -File "%~dp0start_with_auto_install.ps1"
pause
?? check\start_with_auto_install.ps1
powershell
# Auto-Start Backend with Dependency Check
# This script automatically installs missing dependencies and starts the backend
param(
[switch]$SkipDependencyCheck,
[switch]$Force
)
$ErrorActionPreference = "Stop"
# Script directory
$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path
$INSTALL_SCRIPT = Join-Path $SCRIPT_DIR "install_dependencies.ps1"
$DEPENDENCY_MARKER = Join-Path $SCRIPT_DIR ".dependencies_installed"
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "Currency Distributor Backend - Auto Startup" -ForegroundColor Cyan
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host ""
# Check if dependencies need to be installed
$shouldInstall = $false
if ($Force) {
Write-Host "[INFO] Force flag set, will reinstall dependencies" -ForegroundColor Yellow
$shouldInstall = $true
} elseif ($SkipDependencyCheck) {
Write-Host "[INFO] Skipping dependency check" -ForegroundColor Yellow
} elseif (-not (Test-Path $DEPENDENCY_MARKER)) {
Write-Host "[INFO] First-time setup detected" -ForegroundColor Cyan
$shouldInstall = $true
} else {
Write-Host "[INFO] Dependencies previously installed" -ForegroundColor Green
# Quick verification
$tesseractOk = $false
$popplerOk = $false
try {
$null = & tesseract --version 2>&1
$tesseractOk = ($LASTEXITCODE -eq 0)
} catch {}
try {
$null = & pdftoppm -v 2>&1
$popplerOk = ($LASTEXITCODE -eq 0)
} catch {}
if (-not $tesseractOk -or -not $popplerOk) {
Write-Host "[WARNING] Some dependencies missing, will reinstall" -ForegroundColor Yellow
$shouldInstall = $true
}
}
# Install dependencies if needed
if ($shouldInstall) {
Write-Host ""
Write-Host "Installing dependencies..." -ForegroundColor Cyan
Write-Host "This will download and install Tesseract, Poppler, and Python packages..." -ForegroundColor Cyan
Write-Host "This may take 5-10 minutes on first run..." -ForegroundColor Yellow
Write-Host ""
if (Test-Path $INSTALL_SCRIPT) {
# Run installation script
$installArgs = @()
if ($Force) { $installArgs += "-Force" }
& PowerShell.exe -ExecutionPolicy Bypass -File $INSTALL_SCRIPT @installArgs
if ($LASTEXITCODE -eq 0) {
Write-Host ""
Write-Host "[SUCCESS] Dependencies installed successfully!" -ForegroundColor Green
# Create marker file
New-Item -ItemType File -Path $DEPENDENCY_MARKER -Force | Out-Null
Write-Host ""
Write-Host "IMPORTANT: Reloading environment..." -ForegroundColor Yellow
Write-Host ""
# Refresh environment variables in current session
$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User")
} else {
Write-Host ""
Write-Host "[ERROR] Dependency installation failed!" -ForegroundColor Red
Write-Host "[INFO] You can try running manually: .\install_dependencies.ps1" -ForegroundColor Yellow
Write-Host ""
Write-Host "Continuing anyway, some features may not work..." -ForegroundColor Yellow
Write-Host ""
Start-Sleep -Seconds 3
}
} else {
Write-Host "[ERROR] Installation script not found: $INSTALL_SCRIPT" -ForegroundColor Red
Write-Host "Continuing anyway, some features may not work..." -ForegroundColor Yellow
Write-Host ""
}
}
# Start the backend
Write-Host ""
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host "Starting Backend Server..." -ForegroundColor Cyan
Write-Host "==================================================" -ForegroundColor Cyan
Write-Host ""
# Check if virtual environment exists
$VENV_DIR = Join-Path $SCRIPT_DIR "venv"
$VENV_ACTIVATE = Join-Path $VENV_DIR "Scripts\Activate.ps1"
if (Test-Path $VENV_ACTIVATE) {
Write-Host "[INFO] Activating virtual environment..." -ForegroundColor Cyan
& $VENV_ACTIVATE
}
# Check if Python is available
try {
$pythonVersion = & python --version 2>&1
Write-Host "[INFO] Python: $pythonVersion" -ForegroundColor Green
} catch {
Write-Host "[ERROR] Python not found! Please install Python 3.8+" -ForegroundColor Red
Read-Host "Press Enter to exit"
exit 1
}
# Install Python requirements if requirements.txt exists
$REQUIREMENTS_FILE = Join-Path $SCRIPT_DIR "requirements.txt"
if (Test-Path $REQUIREMENTS_FILE) {
Write-Host "[INFO] Checking Python packages..." -ForegroundColor Cyan
try {
# Quick check if main packages are installed
$checkScript = "import fastapi, uvicorn, pytesseract"
$null = & python -c $checkScript 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Host "[INFO] Installing Python packages from requirements.txt..." -ForegroundColor Cyan
& python -m pip install -r $REQUIREMENTS_FILE --quiet
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Python packages installed" -ForegroundColor Green
} else {
Write-Host "[WARNING] Some Python packages may have failed to install" -ForegroundColor Yellow
}
} else {
Write-Host "[INFO] Python packages already installed" -ForegroundColor Green
}
} catch {
Write-Host "[WARNING] Could not verify Python packages: $_" -ForegroundColor Yellow
}
}
# Run the backend
Write-Host ""
Write-Host "Starting server on http://127.0.0.1:8001" -ForegroundColor Green
Write-Host "Press Ctrl+C to stop" -ForegroundColor Yellow
Write-Host ""
# Check if main.py or app/main.py exists
$MAIN_PY = Join-Path $SCRIPT_DIR "app\main.py"
if (-not (Test-Path $MAIN_PY)) {
$MAIN_PY = Join-Path $SCRIPT_DIR "main.py"
}
if (Test-Path $MAIN_PY) {
# Start uvicorn server
& python -m uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload
} else {
Write-Host "[ERROR] main.py not found!" -ForegroundColor Red
Write-Host "Expected location: $MAIN_PY" -ForegroundColor Red
Read-Host "Press Enter to exit"
exit 1
}
?? check\start-server.ps1
powershell
# Start Server - No Installation
# Use this if dependencies are already installed
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Currency Denomination System" -ForegroundColor Cyan
Write-Host "Starting Backend Server" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Navigate to local-backend directory
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $scriptPath
# Create necessary directories
New-Item -ItemType Directory -Force -Path "data" | Out-Null
New-Item -ItemType Directory -Force -Path "exports" | Out-Null
# Display startup information
Write-Host "Server starting on: http://127.0.0.1:8001" -ForegroundColor Green
Write-Host "API Documentation: http://127.0.0.1:8001/docs" -ForegroundColor Green
Write-Host ""
Write-Host "Press Ctrl+C to stop the server" -ForegroundColor Yellow
Write-Host ""
# Start the server
python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8001
?? check\start.ps1
powershell
# Quick Start Script for Local Backend
# Run this script to set up and start the local backend
param(
[switch]$SkipDependencyCheck
)
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Currency Denomination System" -ForegroundColor Cyan
Write-Host "Local Backend - Quick Start" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Navigate to local-backend directory
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $scriptPath
# Check for auto-installer and run if available
$AUTO_INSTALL_SCRIPT = Join-Path $scriptPath "start_with_auto_install.ps1"
if ((Test-Path $AUTO_INSTALL_SCRIPT) -and -not $SkipDependencyCheck) {
Write-Host "Running with automatic dependency installation..." -ForegroundColor Green
Write-Host ""
# Hand off to auto-installer
& PowerShell.exe -ExecutionPolicy Bypass -File $AUTO_INSTALL_SCRIPT
exit $LASTEXITCODE
}
# Fallback: Manual mode (if auto-installer not available or skipped)
Write-Host "Running in manual mode..." -ForegroundColor Yellow
Write-Host ""
# Check Python version
Write-Host "Checking Python version..." -ForegroundColor Yellow
python --version 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
$pyVer = python --version 2>&1
Write-Host "Found: Python installed" -ForegroundColor Green
} else {
Write-Host "Python not found. Please install Python 3.11 or higher." -ForegroundColor Red
exit 1
}
Write-Host ""
Write-Host "Working directory: $pwd" -ForegroundColor Yellow
Write-Host ""
# Note: Using system Python (venv optional)
Write-Host "Using system Python installation" -ForegroundColor Gray
Write-Host ""
# Install/update dependencies
Write-Host "Installing dependencies..." -ForegroundColor Yellow
Write-Host "This may take 2-3 minutes on first run..." -ForegroundColor Gray
# Check if already installed
$pipList = pip list 2>&1 | Out-String
if ($pipList -match "fastapi" -and $pipList -match "uvicorn") {
Write-Host "Dependencies already installed" -ForegroundColor Green
} else {
# Install essential packages only
Write-Host "Installing FastAPI, Uvicorn, SQLAlchemy, Pydantic..." -ForegroundColor Gray
pip install fastapi uvicorn sqlalchemy pydantic --quiet --disable-pip-version-check
if ($LASTEXITCODE -eq 0) {
Write-Host "Dependencies installed" -ForegroundColor Green
} else {
Write-Host "Warning: Some dependencies may not have installed" -ForegroundColor Yellow
Write-Host "The server will attempt to start anyway..." -ForegroundColor Gray
}
}
Write-Host ""
# Create necessary directories
Write-Host "Creating directories..." -ForegroundColor Yellow
New-Item -ItemType Directory -Force -Path "data" | Out-Null
New-Item -ItemType Directory -Force -Path "exports" | Out-Null
Write-Host "Directories created" -ForegroundColor Green
Write-Host ""
# Display startup information
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Starting Local Backend API Server" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "Server will start on: http://127.0.0.1:8001" -ForegroundColor Green
Write-Host "Interactive Docs: http://127.0.0.1:8001/docs" -ForegroundColor Green
Write-Host "Alternative Docs: http://127.0.0.1:8001/redoc" -ForegroundColor Green
Write-Host ""
Write-Host "Press Ctrl+C to stop the server" -ForegroundColor Yellow
Write-Host ""
# Start the server
uvicorn app.main:app --reload --host 127.0.0.1 --port 8001
docs/
?? docs\ARCHITECTURE.md
markdown
# Currency Denomination System - Technical Architecture Document
**Version:** 1.0.0
**Date:** November 22, 2025
**Author:** Currency Denomination System Team
---
## Table of Contents
1. [Executive Summary](#executive-summary)
2. [System Overview](#system-overview)
3. [Architecture Patterns](#architecture-patterns)
4. [Component Design](#component-design)
5. [Data Flow](#data-flow)
6. [Database Design](#database-design)
7. [API Specifications](#api-specifications)
8. [Security Architecture](#security-architecture)
9. [Deployment Architecture](#deployment-architecture)
10. [Performance Considerations](#performance-considerations)
11. [Future Enhancements](#future-enhancements)
---
## 1. Executive Summary
The Currency Denomination System is an enterprise-grade, multi-platform application designed to calculate optimal currency denomination breakdowns for amounts ranging from small values to extremely large amounts (tens of lakh crores).
### Key Characteristics
- **Offline-First Architecture:** Core functionality works without internet
- **Multi-Platform:** Desktop (Electron), Mobile (React Native), Web (Next.js)
- **Highly Scalable:** Supports amounts up to 10^15 (quadrillion) and beyond
- **Extensible:** Plugin-ready architecture for new currencies and optimization strategies
- **Enterprise-Ready:** Public API, multi-user support, analytics, and audit trails
### Technology Stack Summary
| Layer | Technologies |
|-------|-------------|
| **Frontend** | Electron, React, React Native, Next.js, Tailwind CSS |
| **Backend** | Python, FastAPI, Node.js (optional) |
| **Database** | SQLite (local), PostgreSQL (cloud) |
| **Core Logic** | Pure Python (framework-agnostic) |
| **AI/ML** | Google Gemini API |
| **DevOps** | Docker, Kubernetes, GitHub Actions |
---
## 2. System Overview
### 2.1 Architecture Vision
The system follows a **layered architecture** with clear separation of concerns:
```
┌─────────────────────────────────────────────────────────────────┐
│ PRESENTATION LAYER │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Electron │ │React Native │ │ Next.js Web │ │
│ │ Desktop │ │ Mobile │ │ Dashboard │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ APPLICATION/API LAYER │
│ ┌────────────────────────┐ ┌───────────────────────────────┐ │
│ │ Local Backend API │ │ Cloud Backend API │ │
│ │ (FastAPI + SQLite) │ │ (FastAPI + PostgreSQL) │ │
│ │ Offline Mode │ │ Online + Multi-user + Sync │ │
│ └────────────────────────┘ └───────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ DOMAIN/CORE SERVICES │
│ ┌──────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ Denomination │ │FX Service │ │ Optimization Engine │ │
│ │ Engine │ │ │ │ │ │
│ └──────────────┘ └─────────────┘ └──────────────────────────┘ │
│ ┌──────────────┐ ┌─────────────┐ ┌──────────────────────────┐ │
│ │ History │ │ Analytics │ │ Export Service │ │
│ │ Service │ │ Service │ │ │ │
│ └──────────────┘ └─────────────┘ └──────────────────────────┘ │
│ ┌──────────────┐ ┌─────────────┐ │
│ │ Gemini │ │ Auth │ │
│ │ Integration │ │ Service │ │
│ └──────────────┘ └─────────────┘ │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ INFRASTRUCTURE LAYER │
│ ┌────────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────────┐│
│ │ SQLite │ │ PostgreSQL │ │ Redis │ │ S3 Storage ││
│ │ (Local) │ │ (Cloud) │ │ (Cache) │ │ (Files) ││
│ └────────────┘ └──────────────┘ └──────────┘ └──────────────┘│
│ ┌─────────────────────┐ ┌────────────────────────────────────┐│
│ │ External APIs │ │ Monitoring & Logging ││
│ │ (FX, Gemini) │ │ (Grafana, Prometheus) ││
│ └─────────────────────┘ └────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 Operating Modes
#### Offline Mode (Desktop Only)
```
User → Electron UI → Local FastAPI Backend → SQLite DB → Core Engine
```
**Available Features:**
- Single & bulk calculations
- Local history management
- Multi-currency breakdown
- Exports (CSV, Excel, PDF)
- Charts & visualizations
- Settings persistence
**Limitations:**
- No live FX rates (uses cached)
- No AI explanations (requires Gemini API)
- No cross-device sync
#### Online Mode (Full System)
```
User → Desktop/Mobile/Web → Cloud API → PostgreSQL + Redis
↓
External Services (FX, Gemini)
↓
Core Engine → Response
```
**Additional Features:**
- Live exchange rates
- AI-powered explanations & suggestions
- Multi-user authentication
- Cross-device synchronization
- Public API access with rate limiting
- Analytics dashboard
- Cloud backups
---
## 3. Architecture Patterns
### 3.1 Design Patterns Used
#### Repository Pattern
Separates data access logic from business logic.
```python
class CalculationRepository:
def save(self, calculation: Calculation) -> int
def find_by_id(self, id: int) -> Optional[Calculation]
def find_all(self, filters: Dict) -> List[Calculation]
def delete(self, id: int) -> bool
```
#### Strategy Pattern
For different optimization modes:
```python
class OptimizationStrategy(ABC):
@abstractmethod
def optimize(self, amount, currency) -> Result
class GreedyStrategy(OptimizationStrategy):
def optimize(self, amount, currency) -> Result:
# Minimize total count
class MinimizeLargeStrategy(OptimizationStrategy):
def optimize(self, amount, currency) -> Result:
# Avoid large denominations
```
#### Factory Pattern
For creating calculation requests and results:
```python
class CalculationFactory:
@staticmethod
def create_request(data: Dict) -> CalculationRequest:
# Validate and create request
@staticmethod
def create_result(engine_result, metadata) -> CalculationResult:
# Transform engine output to API response
```
#### Observer Pattern (Future)
For real-time updates and sync:
```python
class SyncObserver:
def on_calculation_created(self, calc: Calculation)
def on_calculation_synced(self, calc: Calculation)
```
### 3.2 Architectural Principles
1. **Separation of Concerns**
- Core logic independent of frameworks
- API layer separate from business logic
- UI separate from data access
2. **Dependency Inversion**
- High-level modules don't depend on low-level modules
- Both depend on abstractions
- Core engine has ZERO external dependencies
3. **Single Responsibility**
- Each module has one reason to change
- DenominationEngine: calculation logic only
- FXService: currency conversion only
- HistoryService: persistence only
4. **Open/Closed Principle**
- Open for extension (new currencies, optimization modes)
- Closed for modification (core algorithm stable)
5. **Interface Segregation**
- Small, focused interfaces
- Clients don't depend on methods they don't use
---
## 4. Component Design
### 4.1 Core Denomination Engine
**Location:** `packages/core-engine/`
**Purpose:** Pure Python module for denomination calculations
**Key Classes:**
```python
class DenominationEngine:
"""Main calculation engine"""
def __init__(self, config_path: Optional[str] = None)
def calculate(self, request: CalculationRequest) -> CalculationResult
def get_currency_config(self, currency_code: str) -> CurrencyConfig
def generate_alternatives(self, request, count=3) -> List[CalculationResult]
def validate_amount(self, amount, currency) -> Tuple[bool, Optional[str]]
```
**Algorithm:** Greedy Approach
```python
def _greedy_breakdown(amount, denominations, currency_config):
"""
Time Complexity: O(n) where n = number of denominations
Space Complexity: O(n)
For amount = 50000 INR:
1. 50000 / 2000 = 25 → Use 25 x ?2000
2. Remaining = 0 → Done
Result: 25 notes
"""
remaining = amount
breakdowns = []
for denomination in denominations: # Sorted descending
count = int(remaining / denomination)
if count > 0:
breakdowns.append(DenominationBreakdown(
denomination=denomination,
count=count,
total_value=denomination * count,
is_note=currency_config.is_note(denomination)
))
remaining -= denomination * count
return breakdowns
```
**Why Greedy Works:**
- Currency denominations follow the **canonical system** property
- For canonical systems, greedy always gives optimal solution
- INR, USD, EUR, GBP are all canonical
**Handling Large Numbers:**
```python
from decimal import Decimal
# Supports arbitrary precision
amount = Decimal("1000000000000") # 1 trillion
result = engine.calculate(CalculationRequest(
amount=amount,
currency="INR"
))
# Works perfectly - no overflow or precision loss
```
### 4.2 Optimization Engine
**Purpose:** Apply constraints and generate alternatives
**Constraint Types:**
| Type | Description | Example |
|------|-------------|---------|
| AVOID | Completely exclude denomination | Avoid ?2000 notes |
| MINIMIZE | Reduce usage of denomination | Minimize ?200 notes |
| CAP | Limit maximum count | Max 10 x ?500 |
| REQUIRE | Enforce minimum count | At least 5 x ?100 |
| ONLY | Use only specified denominations | Notes only, no coins |
**Example:**
```python
# Avoid ?2000 notes
constraint = Constraint(
type=ConstraintType.AVOID,
denomination=Decimal("2000")
)
request = CalculationRequest(
amount=Decimal("10000"),
currency="INR",
constraints=[constraint]
)
result = engine.calculate(request)
# Result will use ?500, ?200, ?100 instead of ?2000
```
### 4.3 FX Service
**Purpose:** Currency conversion with offline fallback
**Rate Sources:**
1. Live API (online mode)
2. Cached rates (last fetched)
3. Default rates (fallback)
**Flow:**
```python
def get_exchange_rate(from_curr, to_curr, use_live=True):
if from_curr == to_curr:
return 1.0
if use_live:
rate = fetch_live_rate(from_curr, to_curr)
if rate:
cache_rate(rate) # Save for offline use
return rate
# Fallback to cache
cached = get_cached_rate(from_curr, to_curr)
if cached and not is_stale(cached):
return cached
# Last resort: default rates
return calculate_cross_rate(from_curr, to_curr)
```
### 4.4 Local Backend API
**Technology:** FastAPI 0.104+ with SQLite
**Endpoints:**
```
POST /api/v1/calculate # Calculate denominations
POST /api/v1/alternatives # Get alternative distributions
GET /api/v1/currencies # List currencies
GET /api/v1/currencies/{code} # Currency details
GET /api/v1/exchange-rates # FX rates
GET /api/v1/history # Paginated history
GET /api/v1/history/quick-access # Last 10 for sidebar
GET /api/v1/history/{id} # Single calculation
DELETE /api/v1/history/{id} # Delete calculation
GET /api/v1/history/stats # Statistics
GET /api/v1/export/csv # Export history to CSV
GET /api/v1/export/calculation/{id}/csv # Export single
GET /api/v1/settings # All settings
GET /api/v1/settings/{key} # Single setting
PUT /api/v1/settings # Update setting
POST /api/v1/settings/reset # Reset to defaults
```
**Request/Response Example:**
```json
// Request
POST /api/v1/calculate
{
"amount": 50000,
"currency": "INR",
"optimization_mode": "greedy",
"save_to_history": true
}
// Response
{
"id": 1,
"amount": "50000",
"currency": "INR",
"breakdowns": [
{
"denomination": "2000",
"count": 25,
"total_value": "50000",
"is_note": true
}
],
"total_notes": 25,
"total_coins": 0,
"total_denominations": 25,
"optimization_mode": "greedy",
"created_at": "2025-11-22T10:00:00Z"
}
```
### 4.5 Cloud Backend (To Be Implemented)
**Additional Features:**
- User authentication (JWT)
- Multi-user support
- Public API with API keys
- Rate limiting
- Sync mechanism
- Analytics aggregation
- Gemini integration
---
## 5. Data Flow
### 5.1 Single Calculation Flow
```
1. User enters amount + currency in UI
↓
2. UI sends POST to /api/v1/calculate
↓
3. API validates input
↓
4. API creates CalculationRequest object
↓
5. DenominationEngine.calculate(request)
↓
6. Engine loads currency config
↓
7. Engine runs greedy algorithm
↓
8. Engine returns CalculationResult
↓
9. API saves to database (if requested)
↓
10. API formats response
↓
11. UI displays result with charts
```
**Performance:** ~50ms for typical amounts, <100ms for trillion-scale
### 5.2 Bulk Processing Flow
```
1. User uploads CSV file
↓
2. Backend parses CSV rows
↓
3. For each row:
a. Validate amount + currency
b. Create CalculationRequest
c. Call engine.calculate()
d. Store result
↓
4. Generate summary statistics:
- Total processed
- By currency counts
- Average amount
- Denomination usage aggregates
↓
5. Return results + summary
```
**Optimization:** Batch processing in chunks of 1000 rows
### 5.3 Sync Flow (Future)
```
Desktop (Offline) creates calculation
↓
Stored in local SQLite with synced=false
↓
When online:
↓
Background sync worker starts
↓
Query unsynced calculations
↓
For each unsynced:
a. POST to cloud API
b. Cloud returns cloud_id
c. Update local record: synced=true, cloud_id=X
↓
Pull new calculations from cloud
↓
Merge into local database
```
**Conflict Resolution:** Last-write-wins (timestamp-based)
---
## 6. Database Design
### 6.1 Local Database (SQLite)
**Schema:**
```sql
-- Calculations table
CREATE TABLE calculations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount TEXT NOT NULL, -- Decimal as string
currency TEXT(3) NOT NULL,
source_currency TEXT(3),
target_currency TEXT(3),
exchange_rate TEXT,
optimization_mode TEXT(50) DEFAULT 'greedy',
constraints TEXT, -- JSON
result TEXT NOT NULL, -- JSON
total_notes INTEGER DEFAULT 0,
total_coins INTEGER DEFAULT 0,
total_denominations INTEGER DEFAULT 0,
source TEXT(20) DEFAULT 'desktop',
synced BOOLEAN DEFAULT 0,
cloud_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_calculations_currency ON calculations(currency);
CREATE INDEX idx_calculations_created_at ON calculations(created_at DESC);
CREATE INDEX idx_calculations_synced ON calculations(synced);
-- User settings table
CREATE TABLE user_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE NOT NULL,
value TEXT NOT NULL, -- JSON
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Export records table
CREATE TABLE export_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
export_type TEXT(20) NOT NULL, -- csv, excel, pdf
file_path TEXT NOT NULL,
item_count INTEGER DEFAULT 0,
file_size_bytes INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 6.2 Cloud Database (PostgreSQL) - To Be Implemented
**Additional Tables:**
```sql
-- Users table
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT DEFAULT 'user', -- user, admin
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- API Keys table
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
key TEXT UNIQUE NOT NULL,
name TEXT,
scope JSONB,
rate_limit INTEGER DEFAULT 100, -- requests per hour
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
-- Analytics events table
CREATE TABLE analytics_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id),
event_type TEXT NOT NULL,
metadata JSONB,
timestamp TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_analytics_timestamp ON analytics_events(timestamp DESC);
CREATE INDEX idx_analytics_user ON analytics_events(user_id);
```
---
## 7. API Specifications
### 7.1 REST API Standards
- **Protocol:** HTTPS (production), HTTP (development)
- **Format:** JSON
- **Authentication:** JWT (cloud), None (local)
- **Versioning:** URL path (`/api/v1/...`)
- **Status Codes:**
- 200: Success
- 201: Created
- 400: Bad Request
- 401: Unauthorized
- 404: Not Found
- 429: Rate Limit Exceeded
- 500: Internal Server Error
### 7.2 OpenAPI Documentation
Available at `/docs` (Swagger UI) and `/redoc` (ReDoc)
**Example OpenAPI Spec:**
```yaml
openapi: 3.0.0
info:
title: Currency Denomination System API
version: 1.0.0
description: Calculate denomination breakdowns for any amount
paths:
/api/v1/calculate:
post:
summary: Calculate denomination breakdown
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [amount, currency]
properties:
amount:
type: number
minimum: 0.01
currency:
type: string
pattern: '^[A-Z]{3}#039;
optimization_mode:
type: string
enum: [greedy, constrained, balanced]
```
---
## 8. Security Architecture
### 8.1 Local Backend Security
- **No Authentication:** Local-only, runs on 127.0.0.1
- **Input Validation:** Pydantic models validate all inputs
- **SQL Injection Prevention:** SQLAlchemy ORM prevents SQL injection
- **Path Traversal Prevention:** Whitelist export directories
- **CORS:** Configured for Electron app origin only
### 8.2 Cloud Backend Security (Future)
- **Authentication:** JWT tokens with 24hr expiry
- **Password Hashing:** bcrypt with salt
- **API Keys:** SHA-256 hashed, per-user rate limits
- **HTTPS Only:** Enforce TLS 1.2+
- **Rate Limiting:** Token bucket algorithm
- **Input Sanitization:** Validate and sanitize all inputs
- **Audit Logging:** Track all API calls
---
## 9. Deployment Architecture
### 9.1 Local Desktop Deployment
```
User's Machine:
├── Electron App (Port 3000)
├── Local Backend (Port 8001)
└── SQLite DB (./data/local.db)
```
**Installation:**
1. Download installer (.exe/.dmg/.AppImage)
2. Install desktop app
3. Backend starts automatically with app
4. Ready to use offline
### 9.2 Cloud Deployment (Future)
```
┌─────────────────────────────────────────────────┐
│ Load Balancer │
│ (NGINX / Kong) │
└─────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────┐
│ FastAPI Backend (Kubernetes) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Pod 1 │ │ Pod 2 │ │ Pod 3 │ Auto- │
│ │ │ │ │ │ │ scaling│
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────┘
↓
┌──────────────┐ ┌──────────┐ ┌──────────┐
│ PostgreSQL │ │ Redis │ │ S3 │
│ (Primary + │ │ (Cache) │ │ (Files) │
│ Replica) │ │ │ │ │
└──────────────┘ └──────────┘ └──────────┘
```
---
## 10. Performance Considerations
### 10.1 Computational Complexity
| Operation | Time Complexity | Space Complexity |
|-----------|----------------|------------------|
| Single calculation | O(n) | O(n) |
| Bulk (m items) | O(m * n) | O(m * n) |
| History query | O(log p + k) | O(k) |
| Export | O(m) | O(m) |
Where:
- n = number of denominations (~10-15 typically)
- m = number of calculations
- p = total records in database
- k = items returned
### 10.2 Optimization Techniques
1. **Caching:** Exchange rates cached for 24 hours
2. **Indexing:** Database indexes on frequently queried columns
3. **Pagination:** History queries paginated
4. **Lazy Loading:** Load breakdown details only when needed
5. **Batch Processing:** Bulk operations in chunks
### 10.3 Performance Targets
| Metric | Target | Actual (Measured) |
|--------|--------|-------------------|
| Single calculation | < 100ms | ~50ms |
| API response time (95th percentile) | < 200ms | ~120ms |
| Bulk 1000 items | < 10s | ~5s |
| Database query (100 items) | < 50ms | ~30ms |
---
## 11. Future Enhancements
### Phase 1 (Completed)
- ? Core denomination engine
- ? Local backend API
- ? Multi-currency support
- ? History management
- ? Basic exports (CSV)
### Phase 2 (Next 2-3 months)
- [ ] Electron desktop UI
- [ ] Charts and visualizations
- [ ] Dark mode implementation
- [ ] Excel/PDF exports
- [ ] Cloud backend MVP
### Phase 3 (3-6 months)
- [ ] React Native mobile app
- [ ] User authentication
- [ ] Cloud sync
- [ ] Public API with rate limiting
### Phase 4 (6-12 months)
- [ ] Gemini AI integration
- [ ] Analytics dashboard
- [ ] Multi-language support (i18n)
- [ ] Voice input
- [ ] Plugin marketplace
### Advanced Features (Future)
- [ ] Blockchain audit trail
- [ ] OCR currency scanning
- [ ] Scenario presets
- [ ] Machine learning for usage pattern prediction
- [ ] Real-time collaboration
---
## Conclusion
This architecture provides a solid foundation for a scalable, maintainable, and feature-rich currency denomination system. The clean separation of concerns, offline-first approach, and use of modern technologies ensures the system can grow from a simple desktop app to an enterprise-grade platform.
**Key Strengths:**
- Pure domain logic independent of frameworks
- Offline-first with graceful online enhancement
- Support for extreme large numbers
- Extensible design for future enhancements
- Production-ready architecture patterns
**Next Steps:**
1. Complete desktop UI implementation
2. Deploy cloud backend
3. Implement sync mechanism
4. Add mobile applications
5. Integrate AI features
---
**Document Version:** 1.0.0
**Last Updated:** November 22, 2025
**Maintained By:** Currency Denomination System Team
packages/
core-engine/
config/
?? packages\core-engine\config\currencies.json
json
{
"INR": {
"name": "Indian Rupee",
"symbol": "?",
"code": "INR",
"decimal_places": 2,
"notes": [500, 200, 100, 50, 20, 10],
"coins": [20, 10, 5, 2, 1],
"smallest_unit": 1,
"active": true
},
"USD": {
"name": "US Dollar",
"symbol": "quot;,
"code": "USD",
"decimal_places": 2,
"notes": [100, 50, 20, 10, 5, 2, 1],
"coins": [0.50, 0.25, 0.10, 0.05, 0.01],
"smallest_unit": 0.01,
"active": true
},
"EUR": {
"name": "Euro",
"symbol": "",
"code": "EUR",
"decimal_places": 2,
"notes": [500, 200, 100, 50, 20, 10, 5],
"coins": [2, 1, 0.50, 0.20, 0.10, 0.05, 0.02, 0.01],
"smallest_unit": 0.01,
"active": true
},
"GBP": {
"name": "British Pound",
"symbol": "",
"code": "GBP",
"decimal_places": 2,
"notes": [50, 20, 10, 5],
"coins": [2, 1, 0.50, 0.20, 0.10, 0.05, 0.02, 0.01],
"smallest_unit": 0.01,
"active": true
}
}
?? packages\core-engine\config\fx_rates_cache.json
json
{
"rates": {
"USD_INR": {
"rate": "83.12",
"timestamp": "2025-11-22T10:00:00",
"from": "USD",
"to": "INR"
},
"USD_EUR": {
"rate": "0.92",
"timestamp": "2025-11-22T10:00:00",
"from": "USD",
"to": "EUR"
},
"USD_GBP": {
"rate": "0.79",
"timestamp": "2025-11-22T10:00:00",
"from": "USD",
"to": "GBP"
}
},
"last_updated": "2025-11-22T10:00:00"
}
?? packages\core-engine\__init__.py
python
"""
Currency Denomination Engine - Core Module
This is the brain of the system. Pure Python logic with no framework dependencies.
Handles denomination breakdown for extremely large amounts with arbitrary precision.
Author: Currency Denomination System
License: MIT
"""
__version__ = "1.0.0"
__author__ = "Currency Denomination System Team"
from .engine import DenominationEngine
from .optimizer import OptimizationEngine
from .fx_service import FXService
from .models import (
CalculationRequest,
CalculationResult,
DenominationBreakdown,
OptimizationMode,
Constraint
)
__all__ = [
'DenominationEngine',
'OptimizationEngine',
'FXService',
'CalculationRequest',
'CalculationResult',
'DenominationBreakdown',
'OptimizationMode',
'Constraint'
]
?? packages\core-engine\engine.py
python
"""
Core Denomination Engine
This module implements the core denomination breakdown logic.
Supports arbitrary precision mathematics for extremely large amounts.
Key Features:
- Greedy algorithm optimized for minimal denomination count
- Support for amounts up to 10^15 (quadrillion) and beyond
- Pure integer mathematics to avoid floating-point errors
- Configurable currency denominations
- Thread-safe and stateless design
"""
import json
from decimal import Decimal, ROUND_DOWN
from pathlib import Path
from typing import List, Dict, Optional
from models import (
CalculationRequest,
CalculationResult,
DenominationBreakdown,
CurrencyConfig,
OptimizationMode
)
class DenominationEngine:
"""
Core engine for currency denomination breakdown.
This is a pure, stateless computation engine with no external dependencies.
Can be used across desktop, mobile backend, and cloud services.
"""
def __init__(self, config_path: Optional[str] = None):
"""
Initialize the denomination engine.
Args:
config_path: Path to currencies.json config file.
If None, uses default config location.
"""
if config_path is None:
config_path = Path(__file__).parent / "config" / "currencies.json"
self.currencies = self._load_currency_configs(config_path)
def _load_currency_configs(self, config_path: str) -> Dict[str, CurrencyConfig]:
"""Load currency configurations from JSON file."""
with open(config_path, 'r', encoding='utf-8') as f:
data = json.load(f)
currencies = {}
for code, config in data.items():
currencies[code] = CurrencyConfig(
code=config['code'],
name=config['name'],
symbol=config['symbol'],
decimal_places=config['decimal_places'],
notes=[Decimal(str(n)) for n in config['notes']],
coins=[Decimal(str(c)) for c in config['coins']],
smallest_unit=Decimal(str(config['smallest_unit'])),
active=config.get('active', True)
)
return currencies
def get_currency_config(self, currency_code: str) -> CurrencyConfig:
"""
Get configuration for a specific currency.
Args:
currency_code: 3-letter ISO currency code (e.g., 'INR', 'USD')
Returns:
CurrencyConfig object
Raises:
ValueError: If currency is not supported
"""
currency_code = currency_code.upper()
if currency_code not in self.currencies:
raise ValueError(
f"Currency '{currency_code}' not supported. "
f"Available: {', '.join(self.currencies.keys())}"
)
config = self.currencies[currency_code]
if not config.active:
raise ValueError(f"Currency '{currency_code}' is not currently active")
return config
def calculate(self, request: CalculationRequest) -> CalculationResult:
"""
Calculate denomination breakdown for given amount.
Args:
request: CalculationRequest with amount, currency, and options
Returns:
CalculationResult with denomination breakdown
Raises:
ValueError: If currency not supported or amount invalid
"""
# Get currency configuration
currency_config = self.get_currency_config(request.currency)
# Use the amount (already validated in CalculationRequest.__post_init__)
amount = request.amount
# Get denominations based on optimization mode
denominations = self._get_denominations_for_mode(
currency_config,
request.optimization_mode,
request.constraints
)
# Perform greedy breakdown
breakdowns = self._greedy_breakdown(
amount,
denominations,
currency_config
)
# Calculate totals
total_notes = sum(b.count for b in breakdowns if b.is_note)
total_coins = sum(b.count for b in breakdowns if b.is_coin)
# Create result
result = CalculationResult(
original_amount=request.amount,
currency=request.currency,
breakdowns=breakdowns,
total_notes=total_notes,
total_coins=total_coins,
total_denominations=total_notes + total_coins,
optimization_mode=request.optimization_mode,
constraints_applied=request.constraints,
metadata=request.metadata.copy()
)
return result
def _get_denominations_for_mode(
self,
currency_config: CurrencyConfig,
mode: OptimizationMode,
constraints: List
) -> List[Decimal]:
"""
Get sorted denominations based on optimization mode.
Args:
currency_config: Currency configuration
mode: Optimization mode
constraints: List of constraints
Returns:
Sorted list of denominations to use
"""
all_denoms = currency_config.all_denominations
if mode == OptimizationMode.GREEDY:
# Standard greedy: largest first
return all_denoms
elif mode == OptimizationMode.MINIMIZE_LARGE:
# Reverse order: prefer smaller denominations
return sorted(all_denoms)
elif mode == OptimizationMode.MINIMIZE_SMALL:
# Standard order but could filter out small ones
# For now, same as greedy
return all_denoms
elif mode == OptimizationMode.BALANCED:
# Could implement custom ordering
return all_denoms
else:
# Default to greedy
return all_denoms
def _greedy_breakdown(
self,
amount: Decimal,
denominations: List[Decimal],
currency_config: CurrencyConfig
) -> List[DenominationBreakdown]:
"""
Perform greedy denomination breakdown.
This is the core algorithm. Uses pure decimal arithmetic to avoid
floating-point errors, supporting arbitrarily large amounts.
Args:
amount: Amount to break down
denominations: Sorted list of denominations (descending)
currency_config: Currency configuration
Returns:
List of DenominationBreakdown objects
"""
remaining = amount
breakdowns = []
for denom in denominations:
if remaining <= 0:
break
# Calculate count for this denomination
# Using integer division to avoid floating-point issues
count = int(remaining / denom)
if count > 0:
total_value = denom * count
remaining -= total_value
# Determine if note or coin
is_note = currency_config.is_note(denom)
breakdowns.append(DenominationBreakdown(
denomination=denom,
count=count,
total_value=total_value,
is_note=is_note
))
# Handle rounding errors (should be minimal with Decimal)
if remaining > 0:
# Round down to smallest unit
remaining = remaining.quantize(
currency_config.smallest_unit,
rounding=ROUND_DOWN
)
if remaining > 0:
# Add remaining as metadata warning
# In practice, this should rarely happen with proper denomination sets
pass
return breakdowns
def generate_alternatives(
self,
request: CalculationRequest,
count: int = 3
) -> List[CalculationResult]:
"""
Generate alternative denomination breakdowns.
Args:
request: Original calculation request
count: Number of alternatives to generate
Returns:
List of alternative CalculationResult objects
"""
alternatives = []
# Generate alternatives using different optimization modes
modes = [
OptimizationMode.GREEDY,
OptimizationMode.MINIMIZE_LARGE,
OptimizationMode.BALANCED
]
for mode in modes[:count]:
if mode != request.optimization_mode:
alt_request = CalculationRequest(
amount=request.amount,
currency=request.currency,
optimization_mode=mode,
constraints=request.constraints,
metadata={'alternative_to': str(request.optimization_mode)}
)
result = self.calculate(alt_request)
alternatives.append(result)
return alternatives
def validate_amount(
self,
amount: Decimal,
currency_code: str
) -> tuple[bool, Optional[str]]:
"""
Validate if amount can be broken down with available denominations.
Args:
amount: Amount to validate
currency_code: Currency code
Returns:
Tuple of (is_valid, error_message)
"""
try:
currency_config = self.get_currency_config(currency_code)
# Check if amount is positive
if amount <= 0:
return False, "Amount must be positive"
# Check if amount is multiple of smallest unit
remainder = amount % currency_config.smallest_unit
if remainder != 0:
return False, f"Amount must be multiple of {currency_config.smallest_unit}"
return True, None
except ValueError as e:
return False, str(e)
def get_supported_currencies(self) -> List[str]:
"""Get list of supported currency codes."""
return [code for code, config in self.currencies.items() if config.active]
def get_currency_info(self, currency_code: str) -> Dict:
"""
Get detailed information about a currency.
Args:
currency_code: 3-letter currency code
Returns:
Dictionary with currency details
"""
config = self.get_currency_config(currency_code)
return {
'code': config.code,
'name': config.name,
'symbol': config.symbol,
'decimal_places': config.decimal_places,
'notes': [str(n) for n in config.notes],
'coins': [str(c) for c in config.coins],
'smallest_unit': str(config.smallest_unit),
'total_denominations': len(config.all_denominations)
}
# Convenience function for quick calculations
def calculate_denominations(
amount: float | Decimal | str,
currency: str,
optimization_mode: OptimizationMode = OptimizationMode.GREEDY
) -> CalculationResult:
"""
Quick calculation function.
Args:
amount: Amount to break down (will be converted to Decimal)
currency: 3-letter currency code
optimization_mode: Optimization strategy
Returns:
CalculationResult
Example:
>>> result = calculate_denominations(50000, "INR")
>>> for b in result.breakdowns:
... print(f"{b.count} x {b.denomination} = {b.total_value}")
"""
engine = DenominationEngine()
request = CalculationRequest(
amount=Decimal(str(amount)),
currency=currency,
optimization_mode=optimization_mode
)
return engine.calculate(request)
?? packages\core-engine\fx_service.py
python
"""
Foreign Exchange (FX) Service
Handles currency conversion and exchange rate management.
Supports both live rates (online mode) and cached rates (offline mode).
"""
from decimal import Decimal
from datetime import datetime, timedelta
from typing import Dict, Optional, List
import json
from pathlib import Path
class FXService:
"""
Foreign Exchange service for currency conversion.
Features:
- Live exchange rate fetching (online mode)
- Cached rates for offline use
- Multiple rate providers support
- Historical rate tracking
"""
def __init__(self, cache_path: Optional[str] = None, api_key: Optional[str] = None):
"""
Initialize FX service.
Args:
cache_path: Path to cache file for offline rates
api_key: API key for live rate provider
"""
if cache_path is None:
cache_path = Path(__file__).parent / "config" / "fx_rates_cache.json"
self.cache_path = cache_path
self.api_key = api_key
self.cache = self._load_cache()
# Default base rates (fallback)
self.default_rates = {
'USD': Decimal('1.0'), # Base currency
'EUR': Decimal('0.92'),
'GBP': Decimal('0.79'),
'INR': Decimal('83.12')
}
def _load_cache(self) -> Dict:
"""Load cached exchange rates."""
try:
with open(self.cache_path, 'r') as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {'rates': {}, 'last_updated': None}
def _save_cache(self):
"""Save exchange rates to cache."""
try:
with open(self.cache_path, 'w') as f:
json.dump(self.cache, f, indent=2)
except Exception as e:
print(f"Warning: Could not save FX cache: {e}")
def get_exchange_rate(
self,
from_currency: str,
to_currency: str,
use_live: bool = True
) -> tuple[Decimal, datetime]:
"""
Get exchange rate between two currencies.
Args:
from_currency: Source currency code
to_currency: Target currency code
use_live: Whether to fetch live rate (requires internet)
Returns:
Tuple of (rate, timestamp)
"""
from_currency = from_currency.upper()
to_currency = to_currency.upper()
# If same currency, rate is 1
if from_currency == to_currency:
return Decimal('1.0'), datetime.now()
# Try to get live rate
if use_live:
live_rate = self._fetch_live_rate(from_currency, to_currency)
if live_rate:
return live_rate
# Fall back to cached rate
cache_key = f"{from_currency}_{to_currency}"
if cache_key in self.cache.get('rates', {}):
cached = self.cache['rates'][cache_key]
return (
Decimal(str(cached['rate'])),
datetime.fromisoformat(cached['timestamp'])
)
# Fall back to default rates (for demo purposes)
if from_currency in self.default_rates and to_currency in self.default_rates:
# Calculate cross rate through USD
from_rate = self.default_rates[from_currency]
to_rate = self.default_rates[to_currency]
rate = to_rate / from_rate
# Cache this for offline use
self._cache_rate(from_currency, to_currency, rate, datetime.now())
return rate, datetime.now()
raise ValueError(
f"Exchange rate not available for {from_currency} to {to_currency}"
)
def _fetch_live_rate(
self,
from_currency: str,
to_currency: str
) -> Optional[tuple[Decimal, datetime]]:
"""
Fetch live exchange rate from API.
This is a placeholder. In production, integrate with:
- exchangerate-api.com
- openexchangerates.org
- fixer.io
- or any other FX rate provider
Args:
from_currency: Source currency
to_currency: Target currency
Returns:
Tuple of (rate, timestamp) or None if unavailable
"""
# TODO: Implement actual API call
# For now, return None to use cached/default rates
# Example implementation:
# try:
# import requests
# url = f"https://api.exchangerate-api.com/v4/latest/{from_currency}"
# response = requests.get(url, timeout=5)
# if response.status_code == 200:
# data = response.json()
# rate = Decimal(str(data['rates'][to_currency]))
# timestamp = datetime.now()
#
# # Cache the result
# self._cache_rate(from_currency, to_currency, rate, timestamp)
#
# return rate, timestamp
# except Exception:
# pass
return None
def _cache_rate(
self,
from_currency: str,
to_currency: str,
rate: Decimal,
timestamp: datetime
):
"""Cache an exchange rate."""
cache_key = f"{from_currency}_{to_currency}"
if 'rates' not in self.cache:
self.cache['rates'] = {}
self.cache['rates'][cache_key] = {
'rate': str(rate),
'timestamp': timestamp.isoformat(),
'from': from_currency,
'to': to_currency
}
self.cache['last_updated'] = datetime.now().isoformat()
self._save_cache()
def convert_amount(
self,
amount: Decimal,
from_currency: str,
to_currency: str,
use_live: bool = True
) -> tuple[Decimal, Decimal, datetime]:
"""
Convert amount from one currency to another.
Args:
amount: Amount to convert
from_currency: Source currency
to_currency: Target currency
use_live: Whether to use live rates
Returns:
Tuple of (converted_amount, exchange_rate, rate_timestamp)
"""
rate, timestamp = self.get_exchange_rate(
from_currency,
to_currency,
use_live
)
converted = amount * rate
return converted, rate, timestamp
def get_all_rates(
self,
base_currency: str = 'USD',
use_live: bool = True
) -> Dict[str, Decimal]:
"""
Get exchange rates for all supported currencies.
Args:
base_currency: Base currency for rates
use_live: Whether to fetch live rates
Returns:
Dictionary of currency_code -> rate
"""
supported = ['USD', 'EUR', 'GBP', 'INR']
rates = {}
for currency in supported:
if currency != base_currency:
try:
rate, _ = self.get_exchange_rate(
base_currency,
currency,
use_live
)
rates[currency] = rate
except Exception:
pass
return rates
def get_cache_age(self) -> Optional[timedelta]:
"""Get age of cached rates."""
if self.cache.get('last_updated'):
last_update = datetime.fromisoformat(self.cache['last_updated'])
return datetime.now() - last_update
return None
def is_cache_stale(self, max_age_hours: int = 24) -> bool:
"""Check if cache is stale."""
age = self.get_cache_age()
if age is None:
return True
return age > timedelta(hours=max_age_hours)
def refresh_all_rates(self, base_currency: str = 'USD'):
"""
Refresh all cached rates.
Args:
base_currency: Base currency to fetch rates for
"""
supported = ['USD', 'EUR', 'GBP', 'INR']
for currency in supported:
if currency != base_currency:
try:
self.get_exchange_rate(
base_currency,
currency,
use_live=True
)
except Exception:
pass
?? packages\core-engine\models.py
python
"""
Data models for the core denomination engine.
These are pure Python dataclasses with no external dependencies,
making them portable across all platforms (desktop, mobile backend, cloud).
"""
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Dict, List, Optional, Any
from enum import Enum
class OptimizationMode(str, Enum):
"""Optimization strategies for denomination breakdown."""
GREEDY = "greedy" # Minimize total count
CONSTRAINED = "constrained" # Apply custom constraints
MINIMIZE_LARGE = "minimize_large" # Avoid large denominations
MINIMIZE_SMALL = "minimize_small" # Avoid small denominations
BALANCED = "balanced" # Balance between large and small
AI_SUGGESTED = "ai_suggested" # Gemini-powered suggestions
class ConstraintType(str, Enum):
"""Types of constraints that can be applied."""
MINIMIZE = "minimize" # Minimize usage of specific denomination
AVOID = "avoid" # Completely avoid denomination
CAP = "cap" # Cap maximum count for denomination
REQUIRE = "require" # Require minimum count
ONLY = "only" # Use only specified denominations
@dataclass
class Constraint:
"""Represents a single constraint on denomination breakdown."""
type: ConstraintType
denomination: Optional[Decimal] = None
value: Optional[int] = None # For CAP, REQUIRE
denominations: Optional[List[Decimal]] = None # For ONLY
@dataclass
class DenominationBreakdown:
"""Represents the count for a single denomination."""
denomination: Decimal
count: int
total_value: Decimal
is_note: bool # True for notes, False for coins
def __post_init__(self):
"""Ensure total_value is calculated correctly."""
if self.total_value is None:
self.total_value = self.denomination * self.count
@property
def is_coin(self) -> bool:
"""Check if this is a coin (opposite of is_note)."""
return not self.is_note
@dataclass
class CalculationRequest:
"""Input request for denomination calculation."""
amount: Decimal
currency: str
optimization_mode: OptimizationMode = OptimizationMode.GREEDY
constraints: List[Constraint] = field(default_factory=list)
source_currency: Optional[str] = None # For FX conversion
convert_before_breakdown: bool = True # Convert then breakdown, or vice versa
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self):
"""Validate and normalize the request."""
# Ensure amount is Decimal
if not isinstance(self.amount, Decimal):
self.amount = Decimal(str(self.amount))
# Validate amount is positive
if self.amount <= 0:
raise ValueError("Amount must be positive")
# Normalize currency codes to uppercase
self.currency = self.currency.upper()
if self.source_currency:
self.source_currency = self.source_currency.upper()
@dataclass
class CalculationResult:
"""Output result from denomination calculation."""
original_amount: Decimal
currency: str
breakdowns: List[DenominationBreakdown]
total_notes: int
total_coins: int
total_denominations: int
optimization_mode: OptimizationMode
constraints_applied: List[Constraint]
# FX related fields
source_currency: Optional[str] = None
exchange_rate: Optional[Decimal] = None
converted_amount: Optional[Decimal] = None
# AI/Explanation fields
explanation: Optional[str] = None
alternatives: Optional[List['CalculationResult']] = None
# Metadata
metadata: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
'original_amount': str(self.original_amount),
'currency': self.currency,
'breakdowns': [
{
'denomination': str(b.denomination),
'count': b.count,
'total_value': str(b.total_value),
'is_note': b.is_note
}
for b in self.breakdowns
],
'total_notes': self.total_notes,
'total_coins': self.total_coins,
'total_denominations': self.total_denominations,
'optimization_mode': self.optimization_mode.value,
'constraints_applied': [
{
'type': c.type.value,
'denomination': str(c.denomination) if c.denomination else None,
'value': c.value,
'denominations': [str(d) for d in c.denominations] if c.denominations else None
}
for c in self.constraints_applied
],
'source_currency': self.source_currency,
'exchange_rate': str(self.exchange_rate) if self.exchange_rate else None,
'converted_amount': str(self.converted_amount) if self.converted_amount else None,
'explanation': self.explanation,
'metadata': self.metadata
}
def get_total_value(self) -> Decimal:
"""Calculate total value from all breakdowns."""
return sum(b.total_value for b in self.breakdowns)
@dataclass
class BulkCalculationRequest:
"""Request for bulk processing multiple calculations."""
calculations: List[CalculationRequest]
generate_summary: bool = True
generate_analytics: bool = True
def __post_init__(self):
"""Validate bulk request."""
if not self.calculations:
raise ValueError("At least one calculation required")
@dataclass
class BulkCalculationResult:
"""Result from bulk processing."""
results: List[CalculationResult]
summary: Dict[str, Any]
analytics: Optional[Dict[str, Any]] = None
total_processed: int = 0
successful: int = 0
failed: int = 0
errors: List[Dict[str, str]] = field(default_factory=list)
def __post_init__(self):
"""Calculate stats."""
self.total_processed = len(self.results) + len(self.errors)
self.successful = len(self.results)
self.failed = len(self.errors)
@dataclass
class CurrencyConfig:
"""Configuration for a single currency."""
code: str
name: str
symbol: str
decimal_places: int
notes: List[Decimal]
coins: List[Decimal]
smallest_unit: Decimal
active: bool = True
@property
def all_denominations(self) -> List[Decimal]:
"""Get all denominations (notes + coins) in descending order."""
return sorted(self.notes + self.coins, reverse=True)
def is_note(self, denomination: Decimal) -> bool:
"""Check if denomination is a note."""
return denomination in self.notes
def is_coin(self, denomination: Decimal) -> bool:
"""Check if denomination is a coin."""
return denomination in self.coins
?? packages\core-engine\optimizer.py
python
"""
Optimization Engine
Applies advanced optimization strategies and constraints to denomination breakdowns.
Supports custom constraint logic and alternative distribution generation.
"""
from decimal import Decimal
from typing import List, Dict, Optional
from models import (
CalculationRequest,
CalculationResult,
DenominationBreakdown,
Constraint,
ConstraintType,
OptimizationMode,
CurrencyConfig
)
class OptimizationEngine:
"""
Advanced optimization engine for denomination distribution.
Handles:
- Custom constraints (minimize, avoid, cap, require)
- Alternative distribution generation
- Constraint validation and application
"""
def __init__(self, denomination_engine):
"""
Initialize optimization engine.
Args:
denomination_engine: Instance of DenominationEngine
"""
self.engine = denomination_engine
def apply_constraints(
self,
result: CalculationResult,
constraints: List[Constraint]
) -> CalculationResult:
"""
Apply constraints to a calculation result.
Args:
result: Original calculation result
constraints: List of constraints to apply
Returns:
Modified CalculationResult
"""
if not constraints:
return result
currency_config = self.engine.get_currency_config(result.currency)
modified_breakdowns = result.breakdowns.copy()
for constraint in constraints:
modified_breakdowns = self._apply_single_constraint(
modified_breakdowns,
constraint,
result.original_amount,
currency_config
)
# Recalculate totals
total_notes = sum(b.count for b in modified_breakdowns if b.is_note)
total_coins = sum(b.count for b in modified_breakdowns if b.is_coin)
# Create new result
return CalculationResult(
original_amount=result.original_amount,
currency=result.currency,
breakdowns=modified_breakdowns,
total_notes=total_notes,
total_coins=total_coins,
total_denominations=total_notes + total_coins,
optimization_mode=OptimizationMode.CONSTRAINED,
constraints_applied=constraints,
metadata=result.metadata.copy()
)
def _apply_single_constraint(
self,
breakdowns: List[DenominationBreakdown],
constraint: Constraint,
total_amount: Decimal,
currency_config: CurrencyConfig
) -> List[DenominationBreakdown]:
"""Apply a single constraint to breakdowns."""
if constraint.type == ConstraintType.AVOID:
# Remove specified denomination completely
return [
b for b in breakdowns
if b.denomination != constraint.denomination
]
elif constraint.type == ConstraintType.CAP:
# Cap maximum count for denomination
modified = []
redistributed_value = Decimal(0)
for b in breakdowns:
if b.denomination == constraint.denomination:
if b.count > constraint.value:
# Cap the count
capped_count = constraint.value
capped_value = b.denomination * capped_count
redistributed_value = b.total_value - capped_value
modified.append(DenominationBreakdown(
denomination=b.denomination,
count=capped_count,
total_value=capped_value,
is_note=b.is_note
))
else:
modified.append(b)
else:
modified.append(b)
# Redistribute the excess using smaller denominations
if redistributed_value > 0:
modified = self._redistribute_value(
modified,
redistributed_value,
constraint.denomination,
currency_config
)
return modified
elif constraint.type == ConstraintType.MINIMIZE:
# Try to minimize usage of specific denomination
target_breakdown = next(
(b for b in breakdowns if b.denomination == constraint.denomination),
None
)
if target_breakdown and target_breakdown.count > 0:
# Try to redistribute using other denominations
return self._minimize_denomination(
breakdowns,
constraint.denomination,
currency_config
)
return breakdowns
elif constraint.type == ConstraintType.ONLY:
# Use only specified denominations
allowed = set(constraint.denominations)
filtered = [b for b in breakdowns if b.denomination in allowed]
# Recalculate to ensure amount matches
used_amount = sum(b.total_value for b in filtered)
if used_amount < total_amount:
# Need to recalculate with only allowed denominations
pass
return filtered
return breakdowns
def _redistribute_value(
self,
breakdowns: List[DenominationBreakdown],
value_to_redistribute: Decimal,
avoid_denomination: Decimal,
currency_config: CurrencyConfig
) -> List[DenominationBreakdown]:
"""Redistribute value to other denominations."""
# Get available denominations (excluding the one to avoid)
available_denoms = [
d for d in currency_config.all_denominations
if d != avoid_denomination and d < avoid_denomination
]
remaining = value_to_redistribute
breakdown_dict = {b.denomination: b for b in breakdowns}
for denom in available_denoms:
if remaining <= 0:
break
count = int(remaining / denom)
if count > 0:
if denom in breakdown_dict:
# Add to existing
existing = breakdown_dict[denom]
new_count = existing.count + count
breakdown_dict[denom] = DenominationBreakdown(
denomination=denom,
count=new_count,
total_value=denom * new_count,
is_note=currency_config.is_note(denom)
)
else:
# Create new
breakdown_dict[denom] = DenominationBreakdown(
denomination=denom,
count=count,
total_value=denom * count,
is_note=currency_config.is_note(denom)
)
remaining -= denom * count
# Return sorted by denomination (descending)
return sorted(
breakdown_dict.values(),
key=lambda b: b.denomination,
reverse=True
)
def _minimize_denomination(
self,
breakdowns: List[DenominationBreakdown],
target_denomination: Decimal,
currency_config: CurrencyConfig
) -> List[DenominationBreakdown]:
"""Try to minimize usage of specific denomination."""
# This is a placeholder for more advanced optimization
# Could use linear programming or other optimization techniques
return breakdowns
def suggest_alternatives(
self,
original_request: CalculationRequest,
count: int = 3
) -> List[CalculationResult]:
"""
Generate alternative distributions with explanations.
Args:
original_request: Original calculation request
count: Number of alternatives to generate
Returns:
List of alternative CalculationResult objects
"""
alternatives = []
# Strategy 1: Minimize large denominations
alt1_request = CalculationRequest(
amount=original_request.amount,
currency=original_request.currency,
optimization_mode=OptimizationMode.MINIMIZE_LARGE,
metadata={'strategy': 'minimize_large_notes'}
)
alt1 = self.engine.calculate(alt1_request)
alt1.metadata['explanation'] = "Prefers smaller denominations to minimize large notes"
alternatives.append(alt1)
# Strategy 2: Balanced approach
alt2_request = CalculationRequest(
amount=original_request.amount,
currency=original_request.currency,
optimization_mode=OptimizationMode.BALANCED,
metadata={'strategy': 'balanced'}
)
alt2 = self.engine.calculate(alt2_request)
alt2.metadata['explanation'] = "Balanced distribution between large and small denominations"
alternatives.append(alt2)
# Strategy 3: Avoid coins (if applicable)
currency_config = self.engine.get_currency_config(original_request.currency)
if currency_config.coins:
# Try to avoid coins
avoid_coins_constraint = Constraint(
type=ConstraintType.ONLY,
denominations=currency_config.notes
)
alt3_request = CalculationRequest(
amount=original_request.amount,
currency=original_request.currency,
optimization_mode=OptimizationMode.CONSTRAINED,
constraints=[avoid_coins_constraint],
metadata={'strategy': 'notes_only'}
)
try:
alt3 = self.engine.calculate(alt3_request)
alt3.metadata['explanation'] = "Uses only notes, avoiding coins"
alternatives.append(alt3)
except Exception:
pass # Skip if not possible
return alternatives[:count]
def validate_constraints(
self,
constraints: List[Constraint],
currency_code: str
) -> tuple[bool, Optional[str]]:
"""
Validate that constraints are applicable to the currency.
Args:
constraints: List of constraints
currency_code: Currency code
Returns:
Tuple of (is_valid, error_message)
"""
try:
currency_config = self.engine.get_currency_config(currency_code)
all_denoms = set(currency_config.all_denominations)
for constraint in constraints:
# Check if denomination exists
if constraint.denomination and constraint.denomination not in all_denoms:
return False, f"Denomination {constraint.denomination} not available in {currency_code}"
# Check if value is valid for CAP/REQUIRE
if constraint.type in [ConstraintType.CAP, ConstraintType.REQUIRE]:
if constraint.value is None or constraint.value < 0:
return False, f"Invalid value for {constraint.type.value} constraint"
# Check ONLY constraint
if constraint.type == ConstraintType.ONLY:
if not constraint.denominations:
return False, "ONLY constraint requires list of denominations"
for denom in constraint.denominations:
if denom not in all_denoms:
return False, f"Denomination {denom} not available in {currency_code}"
return True, None
except ValueError as e:
return False, str(e)
?? packages\core-engine\requirements.txt
plaintext
# Core Engine - Pure Python Module
# No external dependencies required for basic functionality
# Optional: For live FX rate fetching
# requests>=2.31.0
# Optional: For enhanced decimal operations
# mpmath>=1.3.0
?? packages\core-engine\test_engine.py
python
"""
Test script for the Core Denomination Engine
Run this to verify the core engine works correctly.
"""
from decimal import Decimal
from engine import DenominationEngine, calculate_denominations
from models import CalculationRequest, OptimizationMode, Constraint, ConstraintType
from optimizer import OptimizationEngine
from fx_service import FXService
def test_basic_calculation():
"""Test basic denomination breakdown."""
print("=" * 60)
print("TEST 1: Basic Denomination Breakdown")
print("=" * 60)
# Test with INR
result = calculate_denominations(50000, "INR")
print(f"\nAmount: ?{result.original_amount:,}")
print(f"Currency: {result.currency}")
print(f"Total Notes: {result.total_notes}")
print(f"Total Coins: {result.total_coins}")
print("\nBreakdown:")
for b in result.breakdowns:
type_str = "note" if b.is_note else "coin"
print(f" {b.count:>4} x ?{b.denomination:>7} = ?{b.total_value:>10,} ({type_str})")
print("\n✓ Test passed!\n")
def test_large_amount():
"""Test with extremely large amount (tens of lakh crores)."""
print("=" * 60)
print("TEST 2: Extremely Large Amount (10 Lakh Crore)")
print("=" * 60)
# 10 lakh crore = 10,00,00,00,00,000 = 1 trillion
amount = Decimal("1000000000000")
result = calculate_denominations(amount, "INR")
print(f"\nAmount: ?{result.original_amount:,}")
print(f"Total Denominations: {result.total_denominations:,}")
print("\nTop 5 denominations:")
for b in result.breakdowns[:5]:
print(f" {b.count:>15,} x ?{b.denomination:>7} = ?{b.total_value:>20,}")
print("\n✓ Test passed!\n")
def test_multi_currency():
"""Test multiple currencies."""
print("=" * 60)
print("TEST 3: Multi-Currency Support")
print("=" * 60)
engine = DenominationEngine()
test_cases = [
(1000, "USD", "quot;),
(5000, "EUR", ""),
(2500, "GBP", ""),
(100000, "INR", "?")
]
for amount, currency, symbol in test_cases:
result = calculate_denominations(amount, currency)
print(f"\n{symbol}{amount:,} {currency}:")
print(f" Total denominations: {result.total_denominations}")
print(f" Largest: {result.breakdowns[0].count} x {symbol}{result.breakdowns[0].denomination}")
print("\n✓ Test passed!\n")
def test_optimization_modes():
"""Test different optimization modes."""
print("=" * 60)
print("TEST 4: Optimization Modes")
print("=" * 60)
amount = Decimal("5000")
currency = "INR"
modes = [
OptimizationMode.GREEDY,
OptimizationMode.MINIMIZE_LARGE
]
for mode in modes:
result = calculate_denominations(amount, currency, mode)
print(f"\nMode: {mode.value}")
print(f" Total denominations: {result.total_denominations}")
print(f" Breakdown: ", end="")
print(" + ".join([f"{b.count}x?{b.denomination}" for b in result.breakdowns[:3]]))
print("\n✓ Test passed!\n")
def test_constraints():
"""Test constraint application."""
print("=" * 60)
print("TEST 5: Constraint Application")
print("=" * 60)
engine = DenominationEngine()
optimizer = OptimizationEngine(engine)
# Test avoiding ?2000 notes
request = CalculationRequest(
amount=Decimal("10000"),
currency="INR",
optimization_mode=OptimizationMode.CONSTRAINED,
constraints=[
Constraint(type=ConstraintType.AVOID, denomination=Decimal("2000"))
]
)
result = engine.calculate(request)
result = optimizer.apply_constraints(result, request.constraints)
print(f"\nAmount: ?{result.original_amount:,}")
print(f"Constraint: Avoid ?2000 notes")
print("\nBreakdown:")
for b in result.breakdowns:
print(f" {b.count} x ?{b.denomination} = ?{b.total_value:,}")
# Verify no ?2000 notes
has_2000 = any(b.denomination == Decimal("2000") for b in result.breakdowns)
assert not has_2000, "Should not have ?2000 notes"
print("\n✓ Test passed!\n")
def test_fx_conversion():
"""Test FX service."""
print("=" * 60)
print("TEST 6: Currency Conversion")
print("=" * 60)
fx_service = FXService()
# Convert 1000 USD to INR
amount = Decimal("1000")
converted, rate, timestamp = fx_service.convert_amount(
amount, "USD", "INR", use_live=False
)
print(f"\nConversion: ${amount:,} USD to INR")
print(f"Exchange Rate: {rate}")
print(f"Converted Amount: ?{converted:,}")
print(f"Rate Timestamp: {timestamp}")
# Now calculate denominations for converted amount
result = calculate_denominations(converted, "INR")
print(f"\nDenomination breakdown of ?{converted:,}:")
for b in result.breakdowns[:5]:
print(f" {b.count} x ?{b.denomination} = ?{b.total_value:,}")
print("\n✓ Test passed!\n")
def test_alternative_suggestions():
"""Test alternative distribution generation."""
print("=" * 60)
print("TEST 7: Alternative Distributions")
print("=" * 60)
engine = DenominationEngine()
optimizer = OptimizationEngine(engine)
request = CalculationRequest(
amount=Decimal("5000"),
currency="INR"
)
alternatives = optimizer.suggest_alternatives(request, count=2)
print(f"\nOriginal amount: ?{request.amount:,}")
print(f"\nGenerated {len(alternatives)} alternatives:")
for i, alt in enumerate(alternatives, 1):
print(f"\nAlternative {i}: {alt.metadata.get('strategy', 'unknown')}")
print(f" Total denominations: {alt.total_denominations}")
print(f" Top 3: ", end="")
print(", ".join([f"{b.count}x?{b.denomination}" for b in alt.breakdowns[:3]]))
print("\n✓ Test passed!\n")
def main():
"""Run all tests."""
print("\n" + "=" * 60)
print("CURRENCY DENOMINATION ENGINE - TEST SUITE")
print("=" * 60 + "\n")
try:
test_basic_calculation()
test_large_amount()
test_multi_currency()
test_optimization_modes()
test_constraints()
test_fx_conversion()
test_alternative_suggestions()
print("=" * 60)
print("ALL TESTS PASSED! ✓")
print("=" * 60)
print("\nThe core engine is working correctly.")
print("Ready to integrate with frontend and backend layers.")
except Exception as e:
print(f"\n❌ TEST FAILED: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()
?? packages\core-engine\test.ps1
powershell
# Test Core Engine - Quick Test Script
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "Currency Denomination Engine" -ForegroundColor Cyan
Write-Host "Running Tests" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
Write-Host ""
# Navigate to core-engine directory
$scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
Set-Location $scriptPath
Write-Host "Working directory: $pwd" -ForegroundColor Yellow
Write-Host ""
# Run quick verification by default
Write-Host "Running Quick Verification - 6 tests..." -ForegroundColor Yellow
Write-Host "For full test suite, run: python test_engine.py" -ForegroundColor Gray
Write-Host ""
python verify.py
if ($LASTEXITCODE -eq 0) {
Write-Host ""
Write-Host "========================================" -ForegroundColor Green
Write-Host "All tests passed!" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Green
Write-Host ""
Write-Host "Tip: Run 'python test_engine.py' for comprehensive 7-test suite" -ForegroundColor Cyan
} else {
Write-Host ""
Write-Host "========================================" -ForegroundColor Red
Write-Host "Tests failed" -ForegroundColor Red
Write-Host "========================================" -ForegroundColor Red
exit 1
}
?? packages\core-engine\verify.py
python
"""
Quick verification script to ensure all components work correctly.
Run this after fixing imports to verify the system is functional.
"""
import sys
from pathlib import Path
print("=" * 70)
print("CURRENCY DENOMINATION SYSTEM - VERIFICATION")
print("=" * 70)
print()
# Test 1: Core Engine Import
print("Test 1: Core Engine Import...")
try:
sys.path.insert(0, str(Path(__file__).parent))
from engine import DenominationEngine, calculate_denominations
from models import OptimizationMode
print("[OK] Core engine imports successful")
except Exception as e:
print(f"[FAIL] Core engine import failed: {e}")
sys.exit(1)
# Test 2: Basic Calculation
print("\nTest 2: Basic Calculation...")
try:
result = calculate_denominations(50000, "INR")
assert result.total_notes == 25
assert str(result.original_amount) == "50000"
print(f"[OK] Calculation successful: Rs.50,000 = {result.total_notes} notes")
except Exception as e:
print(f"[FAIL] Basic calculation failed: {e}")
sys.exit(1)
# Test 3: Multi-Currency Support
print("\nTest 3: Multi-Currency Support...")
try:
import json
config_path = Path(__file__).parent / "config" / "currencies.json"
with open(config_path, 'r', encoding='utf-8') as f:
currency_registry = json.load(f)
currencies = ["INR", "USD", "EUR", "GBP"]
for code in currencies:
info = currency_registry[code]
print(f" [OK] {code}: {info['name']} ({info['symbol']})")
except Exception as e:
print(f"[FAIL] Multi-currency test failed: {e}")
sys.exit(1)
# Test 4: Large Amount Handling
print("\nTest 4: Large Amount Handling...")
try:
large_amount = 1_000_000_000_000 # 1 trillion
result = calculate_denominations(large_amount, "INR")
total_denom = sum(b.count for b in result.breakdowns)
print(f"[OK] Handled Rs.{large_amount:,} = {total_denom:,} denominations")
except Exception as e:
print(f"[FAIL] Large amount test failed: {e}")
sys.exit(1)
# Test 5: FX Service
print("\nTest 5: FX Service...")
try:
from fx_service import FXService
fx = FXService()
rate, timestamp = fx.get_exchange_rate("USD", "INR")
print(f"[OK] FX service working: 1 USD = Rs.{rate}")
except Exception as e:
print(f"[FAIL] FX service test failed: {e}")
sys.exit(1)
# Test 6: Optimization Engine
print("\nTest 6: Optimization Engine...")
try:
from optimizer import OptimizationEngine
from models import CalculationRequest
engine = DenominationEngine()
optimizer = OptimizationEngine(engine)
request = CalculationRequest(amount=5000, currency="INR")
alternatives = optimizer.suggest_alternatives(request, count=2)
print(f"[OK] Generated {len(alternatives)} alternative distributions")
except Exception as e:
print(f"[FAIL] Optimization test failed: {e}")
sys.exit(1)
print()
print("=" * 70)
print("ALL VERIFICATION TESTS PASSED! [OK]")
print("=" * 70)
print()
print("System Status: FULLY OPERATIONAL")
print()
print("Next Steps:")
print(" 1. Start backend: cd ../local-backend && .\\start.ps1")
print(" 2. Visit API docs: http://localhost:8001/docs")
print(" 3. Review docs: See INDEX.md for documentation navigation")
print()
desktop-app/
dist-electron/
?? packages\desktop-app\dist-electron\main.js
javascript
"use strict";
const electron = require("electron");
const path = require("node:path");
process.env.DIST = path.join(__dirname, "../dist");
process.env.VITE_PUBLIC = electron.app.isPackaged ? process.env.DIST : path.join(__dirname, "../public");
let win;
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
function createWindow() {
win = new electron.BrowserWindow({
width: 1200,
height: 800,
icon: path.join(process.env.VITE_PUBLIC || "", "electron-vite.svg"),
webPreferences: {
preload: path.join(__dirname, "preload.js")
}
});
win.webContents.on("did-finish-load", () => {
win == null ? void 0 : win.webContents.send("main-process-message", (/* @__PURE__ */ new Date()).toLocaleString());
});
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL);
} else {
win.loadFile(path.join(process.env.DIST || "", "index.html"));
}
}
electron.app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
electron.app.quit();
win = null;
}
});
electron.app.on("activate", () => {
if (electron.BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
electron.app.whenReady().then(createWindow);
?? packages\desktop-app\dist-electron\preload.js
javascript
"use strict";
const electron = require("electron");
electron.contextBridge.exposeInMainWorld("ipcRenderer", {
on(...args) {
const [channel, listener] = args;
return electron.ipcRenderer.on(channel, (event, ...args2) => listener(event, ...args2));
},
off(...args) {
const [channel, ...omit] = args;
return electron.ipcRenderer.off(channel, ...omit);
},
send(...args) {
const [channel, ...omit] = args;
return electron.ipcRenderer.send(channel, ...omit);
},
invoke(...args) {
const [channel, ...omit] = args;
return electron.ipcRenderer.invoke(channel, ...omit);
}
// You can expose other APTs you need here.
// ...
});
electron/
?? packages\desktop-app\electron\main.ts
plaintext
import { app, BrowserWindow } from 'electron'
import path from 'node:path'
// The built directory structure
//
// ├─┬─ dist
// │ ├─ index.html
// │ ├─ assets
// │ └─ ...
// ├─┬─ dist-electron
// │ ├─ main.js
// │ └─ preload.js
//
process.env.DIST = path.join(__dirname, '../dist')
process.env.VITE_PUBLIC = app.isPackaged ? process.env.DIST : path.join(__dirname, '../public')
let win: BrowserWindow | null
// ?? Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
function createWindow() {
win = new BrowserWindow({
width: 1200,
height: 800,
icon: path.join(process.env.VITE_PUBLIC || '', 'electron-vite.svg'),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
},
})
// Test active push message to Renderer-process.
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', (new Date).toLocaleString())
})
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL)
} else {
// win.loadFile('dist/index.html')
win.loadFile(path.join(process.env.DIST || '', 'index.html'))
}
}
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
win = null
}
})
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
app.whenReady().then(createWindow)
?? packages\desktop-app\electron\preload.ts
plaintext
import { ipcRenderer, contextBridge } from 'electron'
// --------- Expose some API to the Renderer process ---------
contextBridge.exposeInMainWorld('ipcRenderer', {
on(...args: Parameters<typeof ipcRenderer.on>) {
const [channel, listener] = args
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args))
},
off(...args: Parameters<typeof ipcRenderer.off>) {
const [channel, ...omit] = args
return ipcRenderer.off(channel, ...omit)
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args
return ipcRenderer.send(channel, ...omit)
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args
return ipcRenderer.invoke(channel, ...omit)
},
// You can expose other APTs you need here.
// ...
})
src/
components/
?? packages\desktop-app\src\components\BulkUploadPage.tsx
plaintext
import { useState, useRef } from 'react';
import { Upload, FileText, Download, AlertCircle, CheckCircle, XCircle, Loader2, FileDown, Copy, Check, FileImage, FileSpreadsheet } from 'lucide-react';
import { api } from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
interface BulkCalculationRow {
row_number: number;
status: 'success' | 'error';
amount: string;
currency: string;
optimization_mode?: string;
total_notes?: number;
total_coins?: number;
total_denominations?: number;
error?: string;
error_message?: string;
calculation_id?: number;
}
interface BulkUploadResult {
total_rows: number;
successful: number;
failed: number;
processing_time_seconds: number;
saved_to_history: boolean;
results: BulkCalculationRow[];
}
type UploadStatus = 'idle' | 'uploading' | 'processing' | 'completed' | 'error';
export const BulkUploadPage = () => {
const { t } = useLanguage();
const fileInputRef = useRef<HTMLInputElement>(null);
// State management
const [uploadStatus, setUploadStatus] = useState<UploadStatus>('idle');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dragActive, setDragActive] = useState(false);
const [uploadResult, setUploadResult] = useState<BulkUploadResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [saveToHistory, setSaveToHistory] = useState(true);
const [copySuccess, setCopySuccess] = useState(false);
// Supported file types
const SUPPORTED_EXTENSIONS = [
'.csv',
'.pdf',
'.docx', '.doc',
'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.bmp', '.gif', '.webp'
];
// File validation
const validateFile = (file: File): string | null => {
const fileName = file.name.toLowerCase();
const isSupported = SUPPORTED_EXTENSIONS.some(ext => fileName.endsWith(ext));
// Check file type
if (!isSupported) {
return 'Unsupported file format. Please upload CSV, PDF, Word (.docx), or Image files (JPG, PNG, TIFF, BMP, etc.)';
}
// Check file size (max 50MB for images/PDFs, 10MB for others)
const isImageOrPDF = fileName.match(/\.(pdf|jpg|jpeg|png|tiff|tif|bmp|gif|webp)$/);
const maxSize = isImageOrPDF ? 50 * 1024 * 1024 : 10 * 1024 * 1024;
if (file.size > maxSize) {
return `File too large. Maximum size: ${isImageOrPDF ? '50MB' : '10MB'}`;
}
// Check if file is empty
if (file.size === 0) {
return 'File is empty. Please select a valid file.';
}
return null;
};
// Get file type label
const getFileTypeLabel = (fileName: string): string => {
const name = fileName.toLowerCase();
if (name.endsWith('.csv')) return 'CSV';
if (name.endsWith('.pdf')) return 'PDF';
if (name.match(/\.(docx|doc)$/)) return 'Word Document';
if (name.match(/\.(jpg|jpeg|png|tiff|tif|bmp|gif|webp)$/)) return 'Image';
return 'Unknown';
};
// Get file icon
const getFileIcon = (fileName: string) => {
const name = fileName.toLowerCase();
if (name.endsWith('.csv')) return <FileSpreadsheet className="h-8 w-8 text-green-500" />;
if (name.endsWith('.pdf')) return <FileText className="h-8 w-8 text-red-500" />;
if (name.match(/\.(docx|doc)$/)) return <FileText className="h-8 w-8 text-blue-500" />;
if (name.match(/\.(jpg|jpeg|png|tiff|tif|bmp|gif|webp)$/)) return <FileImage className="h-8 w-8 text-purple-500" />;
return <FileText className="h-8 w-8 text-gray-500" />;
};
// Handle file selection
const handleFileSelect = (file: File) => {
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
setSelectedFile(null);
return;
}
setSelectedFile(file);
setError(null);
setUploadResult(null);
};
// Handle file input change
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleFileSelect(file);
}
};
// Handle drag and drop
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === 'dragenter' || e.type === 'dragover') {
setDragActive(true);
} else if (e.type === 'dragleave') {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file) {
handleFileSelect(file);
}
};
// Handle file upload
const handleUpload = async () => {
if (!selectedFile) return;
setUploadStatus('uploading');
setError(null);
try {
const result = await api.uploadBulkCSV(selectedFile, saveToHistory);
setUploadResult(result);
setUploadStatus('completed');
} catch (err: any) {
console.error('Upload error:', err);
setError(err.response?.data?.detail || t('bulkUpload.errors.uploadFailed'));
setUploadStatus('error');
}
};
// Reset to initial state
const handleReset = () => {
setSelectedFile(null);
setUploadResult(null);
setError(null);
setUploadStatus('idle');
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
// Download sample CSV template
const handleDownloadTemplate = () => {
const csvContent = `Amount,Currency,Optimization_Mode
50000,INR,greedy
1000.50,usd,Balanced
5000,EUR,minimize_large
250000,,minimize_small
999.99,GBP,greedy
7500
15000.75,USD,greedy
3200,eur,balanced
500000,inr,MINIMIZE_LARGE
125.50,gbp,minimize_small`;
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', 'bulk_upload_template.csv');
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Export results as CSV
const handleExportResultsCSV = () => {
if (!uploadResult) return;
const headers = ['Row Number', 'Status', 'Amount', 'Currency', 'Optimization Mode', 'Total Notes', 'Total Coins', 'Total Denominations', 'Error'];
const rows = uploadResult.results.map(row => [
row.row_number,
row.status,
row.amount,
row.currency,
row.optimization_mode || '',
row.total_notes || '',
row.total_coins || '',
row.total_denominations || '',
row.error || ''
]);
const csvContent = [headers, ...rows]
.map(row => row.map(cell => `"${cell}"`).join(','))
.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `bulk_upload_results_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Export results as JSON
const handleExportResultsJSON = () => {
if (!uploadResult) return;
const jsonContent = JSON.stringify(uploadResult, null, 2);
const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `bulk_upload_results_${new Date().toISOString().split('T')[0]}.json`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Copy results to clipboard
const handleCopyResults = async () => {
if (!uploadResult) return;
try {
const textContent = `Bulk Upload Results
===================
Total Rows: ${uploadResult.total_rows}
Successful: ${uploadResult.successful}
Failed: ${uploadResult.failed}
Processing Time: ${uploadResult.processing_time_seconds.toFixed(2)}s
Saved to History: ${uploadResult.saved_to_history ? 'Yes' : 'No'}
Detailed Results:
${uploadResult.results.map(row =>
row.status === 'success'
? `Row ${row.row_number}: ✓ ${row.amount} ${row.currency} → ${row.total_denominations} denominations`
: `Row ${row.row_number}: ✗ ${row.error}`
).join('\n')}`;
await navigator.clipboard.writeText(textContent);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 3000);
} catch (err) {
console.error('Failed to copy:', err);
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2">
Bulk Upload & Processing
</h2>
<p className="text-gray-600 dark:text-gray-400">
Upload CSV, PDF, Word documents, or images for batch denomination calculations with OCR support
</p>
</div>
<button
onClick={handleDownloadTemplate}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
<Download className="w-4 h-4" />
Download CSV Template
</button>
</div>
</div>
{/* Upload Section */}
{uploadStatus === 'idle' || uploadStatus === 'error' ? (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
{/* Drag and Drop Area */}
<div
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`border-2 border-dashed rounded-lg p-12 text-center transition-colors ${
dragActive
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
}`}
>
<Upload className={`w-16 h-16 mx-auto mb-4 ${
dragActive ? 'text-blue-500' : 'text-gray-400 dark:text-gray-500'
}`} />
{selectedFile ? (
<div className="space-y-4">
<div className="inline-flex items-center gap-4 px-6 py-4 bg-gradient-to-r from-green-50 to-emerald-50 dark:from-green-900/20 dark:to-emerald-900/20 rounded-lg border border-green-200 dark:border-green-800\">
{getFileIcon(selectedFile.name)}
<div className="text-left flex-1">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-1">Selected File:</p>
<p className="font-semibold text-gray-900 dark:text-gray-100 text-lg">
{selectedFile.name}
</p>
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 dark:bg-blue-900/30 text-xs font-medium text-blue-700 dark:text-blue-300">
{getFileTypeLabel(selectedFile.name)}
</span>
<span className="text-xs text-gray-600 dark:text-gray-400">
{(selectedFile.size / 1024).toFixed(2)} KB
</span>
</div>
</div>
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<button
onClick={() => {
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}}
className="text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 font-medium"
>
Remove File
</button>
</div>
) : (
<div className="space-y-2">
<p className="text-lg font-medium text-gray-900 dark:text-gray-100">
Drag & drop your file here
</p>
<p className="text-sm text-gray-500 dark:text-gray-400">
or click to browse
</p>
<div className="mt-4">
<label className="inline-flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg cursor-pointer transition-colors">
<FileText className="w-4 h-4" />
Choose File
<input
ref={fileInputRef}
type="file"
accept=".csv,.pdf,.docx,.doc,.jpg,.jpeg,.png,.tiff,.tif,.bmp,.gif,.webp"
onChange={handleFileInputChange}
className="hidden"
/>
</label>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-3">
Supported: CSV, PDF, Word (.docx), Images (JPG, PNG, TIFF, BMP, etc.)
</p>
</div>
)}
</div>
{/* File Requirements */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-200 mb-2">
File Requirements & Supported Formats
</h3>
<div className="grid md:grid-cols-2 gap-4">
<div>
<p className="text-xs font-medium text-blue-800 dark:text-blue-300 mb-1">Supported Formats:</p>
<ul className="text-xs text-blue-700 dark:text-blue-300 space-y-0.5">
<li> CSV files (.csv)</li>
<li> PDF documents (.pdf) - text or scanned</li>
<li> Word documents (.docx)</li>
<li> Images (JPG, PNG, TIFF, BMP, etc.)</li>
</ul>
</div>
<div>
<p className="text-xs font-medium text-blue-800 dark:text-blue-300 mb-1">Requirements:</p>
<ul className="text-xs text-blue-700 dark:text-blue-300 space-y-0.5">
<li> Required: Amount and Currency</li>
<li> Optional: Optimization Mode</li>
<li> Max size: 50MB (images/PDFs), 10MB (others)</li>
<li> OCR automatically extracts data from images/PDFs</li>
</ul>
</div>
</div>
</div>
{/* Error Display */}
{error && (
<div className="mt-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-red-900 dark:text-red-200">
Upload Error
</p>
<p className="text-sm text-red-700 dark:text-red-300 mt-1">
{error}
</p>
</div>
</div>
)}
{/* Action Buttons */}
<div className="mt-6 flex items-center gap-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="saveToHistory"
checked={saveToHistory}
onChange={(e) => setSaveToHistory(e.target.checked)}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<label htmlFor="saveToHistory" className="text-sm text-gray-700 dark:text-gray-300">
Save to History
</label>
</div>
<button
onClick={handleUpload}
disabled={!selectedFile}
className="flex items-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-gray-600 disabled:cursor-not-allowed text-white rounded-lg transition-colors font-medium"
>
<Upload className="w-4 h-4" />
Upload & Process
</button>
</div>
</div>
) : null}
{/* Processing Status */}
{(uploadStatus === 'uploading' || uploadStatus === 'processing') && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-12 border border-gray-200 dark:border-gray-700">
<div className="text-center">
<Loader2 className="w-16 h-16 mx-auto mb-4 text-blue-600 dark:text-blue-400 animate-spin" />
<h3 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-2">
{uploadStatus === 'uploading' ? 'Uploading File...' : 'Processing Data...'}
</h3>
<p className="text-gray-600 dark:text-gray-400">
{uploadStatus === 'uploading'
? 'Sending file to server...'
: 'Extracting and calculating denominations. This may take a moment for images and PDFs.'}
</p>
{selectedFile && (
<div className="mt-4 inline-flex items-center gap-3 px-6 py-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
{getFileIcon(selectedFile.name)}
<div className="text-left">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
{selectedFile.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{getFileTypeLabel(selectedFile.name)} {(selectedFile.size / 1024).toFixed(2)} KB
</p>
</div>
</div>
)}
</div>
</div>
)}
{/* Results Display */}
{uploadStatus === 'completed' && uploadResult && (
<div className="space-y-6">
{/* File Information Card */}
{selectedFile && (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 rounded-lg shadow-sm p-4 border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-4">
{getFileIcon(selectedFile.name)}
<div className="flex-1">
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">Processed File:</p>
<p className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{selectedFile.name}
</p>
<div className="flex items-center gap-4 mt-1">
<span className="text-xs text-gray-600 dark:text-gray-400">
Format: <span className="font-medium text-blue-600 dark:text-blue-400">{getFileTypeLabel(selectedFile.name)}</span>
</span>
<span className="text-xs text-gray-600 dark:text-gray-400">
Size: <span className="font-medium">{(selectedFile.size / 1024).toFixed(2)} KB</span>
</span>
<span className="text-xs text-gray-600 dark:text-gray-400">
Processed: <span className="font-medium">{new Date().toLocaleString()}</span>
</span>
</div>
</div>
</div>
</div>
)}
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<FileText className="w-10 h-10 text-blue-600 dark:text-blue-400" />
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Total Rows</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">{uploadResult.total_rows}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<CheckCircle className="w-10 h-10 text-green-600 dark:text-green-400" />
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Successful</p>
<p className="text-2xl font-bold text-green-600 dark:text-green-400">{uploadResult.successful}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<XCircle className="w-10 h-10 text-red-600 dark:text-red-400" />
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Failed</p>
<p className="text-2xl font-bold text-red-600 dark:text-red-400">{uploadResult.failed}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-6 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<Loader2 className="w-10 h-10 text-purple-600 dark:text-purple-400" />
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Processing Time</p>
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
{uploadResult.processing_time_seconds.toFixed(2)}s
</p>
</div>
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-4">
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
>
<Upload className="w-4 h-4" />
Upload Another File
</button>
<button
onClick={handleExportResultsCSV}
className="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors"
>
<FileDown className="w-4 h-4" />
Export as CSV
</button>
<button
onClick={handleExportResultsJSON}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
<FileDown className="w-4 h-4" />
Export as JSON
</button>
<button
onClick={handleCopyResults}
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors"
>
{copySuccess ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copySuccess ? 'Copied!' : 'Copy Results'}
</button>
</div>
{/* Results Table */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Row
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Amount
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Currency
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Denominations
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">
Details
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
{uploadResult.results.map((row) => (
<tr key={row.row_number} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{row.row_number}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{row.status === 'success' ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300">
<CheckCircle className="w-3 h-3" />
Success
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300">
<XCircle className="w-3 h-3" />
Error
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{row.amount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{row.currency}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{row.status === 'success' ? (
<div className="flex items-center gap-4">
<span className="text-blue-600 dark:text-blue-400">
{row.total_notes} notes
</span>
<span className="text-green-600 dark:text-green-400">
{row.total_coins} coins
</span>
</div>
) : (
<span className="text-gray-400 dark:text-gray-500"></span>
)}
</td>
<td className="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
{row.status === 'success' ? (
<span className="text-gray-600 dark:text-gray-400">
{row.total_denominations} total denominations
</span>
) : (
<span className="text-red-600 dark:text-red-400">
{row.error_message || row.error || 'Processing failed'}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
);
};
?? packages\desktop-app\src\components\CalculationForm.tsx
plaintext
import React, { useState, useEffect } from 'react';
import { ArrowRight, RefreshCw, Sparkles } from 'lucide-react';
import { api, CalculationResult } from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import { useSmartCurrency } from '../hooks/useSmartCurrency';
interface CalculationFormProps {
onCalculationComplete: (result: CalculationResult) => void;
}
export const CalculationForm: React.FC<CalculationFormProps> = ({ onCalculationComplete }) => {
const { t } = useLanguage();
const { recommendedCurrency, confidence, reason, recordUsage } = useSmartCurrency();
const [amount, setAmount] = useState<string>('');
const [currency, setCurrency] = useState<string>('INR');
const [optimizationMode, setOptimizationMode] = useState<string>('greedy');
const [showAdvanced, setShowAdvanced] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [autoSaveHistory, setAutoSaveHistory] = useState<boolean>(true);
const [showSmartCurrencyHint, setShowSmartCurrencyHint] = useState(false);
useEffect(() => {
// Load default settings
const loadSettings = async () => {
try {
// Load auto-save setting
const autoSaveResponse = await api.getSetting('auto_save_history');
if (autoSaveResponse.exists) {
setAutoSaveHistory(autoSaveResponse.value);
}
// Priority 1: Check if user has saved a preferred currency in settings
const currencyResponse = await api.getSetting('default_currency');
if (currencyResponse.exists) {
setCurrency(currencyResponse.value);
} else if (recommendedCurrency) {
// Priority 2: Use smart currency recommendation
setCurrency(recommendedCurrency);
// Show hint only if confidence is high or medium
if (confidence && (confidence === 'high' || confidence === 'medium')) {
setShowSmartCurrencyHint(true);
// Auto-hide hint after 5 seconds
setTimeout(() => setShowSmartCurrencyHint(false), 5000);
}
}
// Load default optimization mode
const modeResponse = await api.getSetting('default_optimization_mode');
if (modeResponse.exists) {
setOptimizationMode(modeResponse.value);
}
} catch (error) {
console.error('Failed to load settings:', error);
}
};
loadSettings();
// Poll for auto-save setting changes every 2 seconds
const checkAutoSaveUpdate = async () => {
try {
const response = await api.getSetting('auto_save_history');
if (response.exists && response.value !== autoSaveHistory) {
setAutoSaveHistory(response.value);
}
} catch (error) {
// Silently fail - don't spam console
}
};
const intervalId = setInterval(checkAutoSaveUpdate, 2000);
return () => clearInterval(intervalId);
}, [autoSaveHistory, recommendedCurrency, confidence]);
const currencySymbols: Record<string, string> = {
'INR': '?',
'USD': '#039;,
'EUR': '',
'GBP': ''
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
// Don't convert to number, send as string to preserve precision for large numbers
if (!amount || amount.trim() === '') {
throw new Error(t('calculator.enterAmount'));
}
const numAmount = parseFloat(amount);
if (isNaN(numAmount) || numAmount <= 0) {
throw new Error(t('calculator.validAmount'));
}
// Warn for extremely large numbers
if (amount.length > 30) {
if (!confirm(t('calculator.largeAmountWarning'))) {
setLoading(false);
return;
}
}
const result = await api.calculate({
amount: amount, // Send as string to preserve precision
currency: currency,
optimization_mode: optimizationMode,
save_to_history: autoSaveHistory
});
// Record currency usage for smart recommendations
recordUsage(currency);
onCalculationComplete(result);
} catch (err: any) {
if (err.code === 'ERR_NETWORK') {
setError(t('calculator.networkError'));
} else {
setError(err.response?.data?.detail || err.message || 'An error occurred');
}
} finally {
setLoading(false);
}
};
return (
<div className="relative">
{/* Background Gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-800 dark:to-gray-900 rounded-2xl opacity-50"></div>
<div className="relative bg-white/80 dark:bg-gray-800/80 backdrop-blur-xl rounded-2xl shadow-2xl border border-gray-200/50 dark:border-gray-700/50 p-8">
<div className="text-center mb-6">
<h2 className="text-3xl font-bold bg-gradient-to-r from-blue-600 to-indigo-600 dark:from-blue-400 dark:to-indigo-400 bg-clip-text text-transparent mb-2">{t('calculator.title')}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('calculator.subtitle')}</p>
</div>
{/* Smart Currency Hint */}
{showSmartCurrencyHint && reason && (
<div className="mb-4 p-3 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-900/20 dark:to-indigo-900/20 border border-blue-200 dark:border-blue-700 rounded-lg flex items-center gap-2">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<p className="text-xs text-blue-800 dark:text-blue-300">
<strong>Smart Currency:</strong> {reason}
</p>
<button
onClick={() => setShowSmartCurrencyHint(false)}
className="ml-auto text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-200"
type="button"
>
</button>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{/* Currency Selector */}
<div className="md:col-span-2">
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
{t('calculator.currency')}
</label>
<div className="relative">
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="w-full h-14 px-4 pr-10 border-2 border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 font-semibold text-lg appearance-none cursor-pointer hover:border-blue-400 dark:hover:border-blue-500"
>
<option value="INR">? INR</option>
<option value="USD">$ USD</option>
<option value="EUR"> EUR</option>
<option value="GBP"> GBP</option>
</select>
<div className="absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none">
<svg className="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
</div>
{/* Amount Input */}
<div className="md:col-span-3">
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
{t('calculator.amount')}
</label>
<div className="relative">
<span className="absolute left-5 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 text-2xl font-bold">
{currencySymbols[currency]}
</span>
<input
type="text"
value={amount}
onChange={(e) => {
const value = e.target.value;
if (value === '' || /^\d*\.?\d{0,2}$/.test(value)) {
setAmount(value);
}
}}
placeholder={t('calculator.amountPlaceholder')}
className="w-full h-14 pl-14 pr-6 border-2 border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 font-bold text-2xl hover:border-blue-400 dark:hover:border-blue-500 caret-blue-600 dark:caret-blue-400"
autoComplete="off"
/>
</div>
</div>
</div>
{/* Advanced Options - Collapsible */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<svg className={`w-4 h-4 transition-transform ${showAdvanced ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
{t('calculator.advancedOptions')}
</button>
{showAdvanced && (
<div className="mt-4">
<label className="block text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
{t('calculator.optimizationMode')}
</label>
<select
value={optimizationMode}
onChange={(e) => setOptimizationMode(e.target.value)}
className="w-full h-12 px-4 border-2 border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none transition-all bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 hover:border-blue-400 dark:hover:border-blue-500"
>
<option value="greedy">{t('calculator.greedy')}</option>
<option value="balanced">{t('calculator.balanced')}</option>
<option value="minimize_large">{t('calculator.minimizeLarge')}</option>
<option value="minimize_small">{t('calculator.minimizeSmall')}</option>
</select>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Choose how to optimize the denomination breakdown
</p>
</div>
)}
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-sm rounded-lg border border-red-100 dark:border-red-800">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full h-14 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 dark:from-blue-500 dark:to-indigo-500 dark:hover:from-blue-600 dark:hover:to-indigo-600 text-white font-bold text-lg rounded-xl transition-all flex items-center justify-center gap-3 disabled:opacity-70 disabled:cursor-not-allowed shadow-lg hover:shadow-xl transform hover:-translate-y-0.5"
>
{loading ? (
<>
<RefreshCw className="w-5 h-5 animate-spin" />
{t('calculator.calculating')}
</>
) : (
<>
{t('calculator.calculate')}
<ArrowRight className="w-5 h-5" />
</>
)}
</button>
</form>
</div>
</div>
);
};
?? packages\desktop-app\src\components\HistoryPage.tsx
plaintext
import { useState, useEffect } from 'react';
import { Trash2, Download, Eye, CheckSquare, Square, Loader2, AlertCircle, FileText, Printer, ChevronDown, Copy, Check } from 'lucide-react';
import { api, HistoryResponse } from '../services/api';
import { formatDateTime } from '../utils/dateFormatter';
import { useLanguage } from '../contexts/LanguageContext';
// Helper function to format large numbers
const formatLargeNumber = (value: string | number): string => {
const numStr = value.toString();
const num = parseFloat(numStr);
// For very large numbers (>= 1 billion), use compact notation
if (num >= 1e15) {
return num.toExponential(2);
} else if (num >= 1e12) {
return (num / 1e12).toFixed(2) + 'T';
} else if (num >= 1e9) {
return (num / 1e9).toFixed(2) + 'B';
} else if (num >= 1e6) {
return (num / 1e6).toFixed(2) + 'M';
} else if (num >= 1e3) {
return num.toLocaleString();
}
return numStr;
};
// Helper function to convert number to words with multi-currency support
const numberToWords = (num: number, currency: string = 'INR'): string => {
if (num === 0) return 'zero';
if (num >= 1e15) return formatLargeNumber(num); // Too large for words
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
const teens = ['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
const convertLessThanThousand = (n: number): string => {
if (n === 0) return '';
if (n < 10) return ones[n];
if (n < 20) return teens[n - 10];
if (n < 100) {
const ten = Math.floor(n / 10);
const one = n % 10;
return tens[ten] + (one ? ' ' + ones[one] : '');
}
const hundred = Math.floor(n / 100);
const rest = n % 100;
return ones[hundred] + ' hundred' + (rest ? ' ' + convertLessThanThousand(rest) : '');
};
// Indian numbering system for INR
if (currency === 'INR') {
const crore = Math.floor(num / 10000000);
const lakh = Math.floor((num % 10000000) / 100000);
const thousand = Math.floor((num % 100000) / 1000);
const remainder = num % 1000;
let words = '';
if (crore > 0) words += convertLessThanThousand(crore) + ' crore ';
if (lakh > 0) words += convertLessThanThousand(lakh) + ' lakh ';
if (thousand > 0) words += convertLessThanThousand(thousand) + ' thousand ';
if (remainder > 0) words += convertLessThanThousand(remainder);
return words.trim();
}
// Western/International numbering system (USD, EUR, GBP, etc.)
const billion = Math.floor(num / 1000000000);
const million = Math.floor((num % 1000000000) / 1000000);
const thousand = Math.floor((num % 1000000) / 1000);
const remainder = num % 1000;
let words = '';
if (billion > 0) words += convertLessThanThousand(billion) + ' billion ';
if (million > 0) words += convertLessThanThousand(million) + ' million ';
if (thousand > 0) words += convertLessThanThousand(thousand) + ' thousand ';
if (remainder > 0) words += convertLessThanThousand(remainder);
return words.trim();
};
export const HistoryPage = () => {
const { t } = useLanguage();
const [history, setHistory] = useState<HistoryResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [filterCurrency, setFilterCurrency] = useState<string>('');
const [showExportMenu, setShowExportMenu] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
// Copy functionality
const handleCopyAll = async () => {
if (!history || history.items.length === 0) {
alert(t('history.nothingToCopy'));
return;
}
try {
const textContent = generateHistoryText(history.items);
await navigator.clipboard.writeText(textContent);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 3000);
} catch (error) {
console.error('Failed to copy:', error);
alert(t('results.copyFailed'));
}
};
const handleCopySelected = async () => {
if (selectedIds.size === 0) {
alert(t('history.nothingToCopy'));
return;
}
try {
const selectedItems = history?.items.filter(item => selectedIds.has(item.id)) || [];
const textContent = generateHistoryText(selectedItems);
await navigator.clipboard.writeText(textContent);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 3000);
} catch (error) {
console.error('Failed to copy:', error);
alert(t('results.copyFailed'));
}
};
const generateHistoryText = (items: any[]): string => {
let text = `${t('history.title')}\n`;
text += `${'='.repeat(80)}\n`;
text += `${t('history.totalCalculations')}: ${items.length}\n\n`;
items.forEach((item, index) => {
text += `${index + 1}. ${t('history.date')}: ${formatDateTime(item.created_at)}\n`;
text += ` ${t('history.amount')}: ${formatLargeNumber(item.amount)} ${item.currency}\n`;
text += ` ${t('history.notes')}: ${formatLargeNumber(item.total_notes)} | `;
text += `${t('history.coins')}: ${formatLargeNumber(item.total_coins)} | `;
text += `${t('history.total')}: ${formatLargeNumber(item.total_denominations)}\n`;
if (item.optimization_mode) {
text += ` ${t('history.optimizationMode')}: ${item.optimization_mode}\n`;
}
text += '\n';
});
return text;
};
const loadHistory = async () => {
setLoading(true);
setError(null);
try {
const data = await api.getHistory(currentPage, 50, filterCurrency || undefined);
setHistory(data);
} catch (err: any) {
setError(err.message || 'Failed to load history');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadHistory();
}, [currentPage, filterCurrency]);
const toggleSelect = (id: number) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
};
const toggleSelectAll = () => {
if (selectedIds.size === history?.items.length) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(history?.items.map(item => item.id) || []));
}
};
const handleDelete = async (id: number) => {
if (!confirm(t('history.confirmDelete'))) return;
try {
await api.deleteCalculation(id);
await loadHistory();
setSelectedIds(new Set());
} catch (err: any) {
alert(t('history.deleteFailed') + ': ' + err.message);
}
};
const handleBulkDelete = async () => {
if (selectedIds.size === 0) return;
if (!confirm(t('history.confirmDeleteSelected', { count: selectedIds.size }))) return;
try {
await api.bulkDeleteCalculations(Array.from(selectedIds));
await loadHistory();
setSelectedIds(new Set());
} catch (err: any) {
alert(t('history.deleteFailed') + ': ' + err.message);
}
};
const handleDeleteAll = async () => {
if (!history || history.items.length === 0) {
alert(t('history.noHistoryToDelete'));
return;
}
const totalCount = history.total;
const confirmMessage = filterCurrency
? t('history.confirmDeleteAll', { count: totalCount, currency: filterCurrency })
: t('history.confirmDeleteAllGlobal', { count: totalCount });
if (!confirm(confirmMessage)) return;
// Double confirmation for safety
if (!confirm(t('history.confirmDeletePermanent'))) return;
try {
// Let the backend handle the filtering and deletion efficiently
const result = await api.deleteAllHistory(filterCurrency || undefined);
await loadHistory();
setSelectedIds(new Set());
alert(t('history.deleteSuccess', { count: result.deleted_count }));
} catch (err: any) {
alert(t('history.deleteFailed') + ': ' + err.message);
}
};
const handleExport = async (selectedOnly = false) => {
try {
const ids = selectedOnly ? Array.from(selectedIds) : undefined;
const blob = await api.exportHistoryCSV(ids, filterCurrency || undefined);
// Download the file
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `history_${new Date().toISOString().split('T')[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err: any) {
alert(t('history.exportFailed') + ': ' + err.message);
}
};
const handleExportPDF = async () => {
if (!history || history.items.length === 0) {
alert(t('history.noHistoryToExport'));
return;
}
const htmlContent = generatePrintableHTML();
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(htmlContent);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 250);
}
setShowExportMenu(false);
};
const handleExportWord = async () => {
if (!history || history.items.length === 0) {
alert(t('history.noHistoryToExport'));
return;
}
const htmlContent = generateWordHTML();
const blob = new Blob([htmlContent], { type: 'application/msword' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `calculation-history-${new Date().toISOString().split('T')[0]}.doc`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
setShowExportMenu(false);
};
const handlePrint = () => {
if (!history || history.items.length === 0) {
alert(t('history.noHistoryToExport'));
return;
}
const htmlContent = generatePrintableHTML();
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(htmlContent);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 250);
}
setShowExportMenu(false);
};
const generatePrintableHTML = (): string => {
if (!history) return '';
return `
<!DOCTYPE html>
<html>
<head>
<title>${t('history.title')}</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
h1 {
color: #1f2937;
border-bottom: 3px solid #3b82f6;
padding-bottom: 10px;
margin-bottom: 20px;
}
.meta {
color: #6b7280;
margin-bottom: 30px;
font-size: 14px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th {
background-color: #f3f4f6;
padding: 12px;
text-align: left;
font-weight: 600;
color: #374151;
border: 1px solid #e5e7eb;
}
td {
padding: 10px 12px;
border: 1px solid #e5e7eb;
color: #1f2937;
}
tr:nth-child(even) {
background-color: #f9fafb;
}
.footer {
margin-top: 30px;
text-align: center;
color: #9ca3af;
font-size: 12px;
border-top: 1px solid #e5e7eb;
padding-top: 20px;
}
@media print {
body { padding: 0; }
.no-print { display: none; }
}
</style>
</head>
<body>
<h1>${t('history.title')}</h1>
<div class="meta">
<p><strong>${t('history.generated')}:</strong> ${formatDateTime(new Date().toISOString())}</p>
<p><strong>${t('history.total')}:</strong> ${history.total}</p>
${filterCurrency ? `<p><strong>${t('history.filteredBy')}:</strong> ${filterCurrency}</p>` : ''}
</div>
<table>
<thead>
<tr>
<th>${t('history.dateTime')}</th>
<th>${t('history.amount')}</th>
<th>${t('history.currency')}</th>
<th>${t('history.notes')}</th>
<th>${t('history.coins')}</th>
<th>${t('history.totalDenominations')}</th>
<th>${t('history.optimizationMode')}</th>
</tr>
</thead>
<tbody>
${history.items.map(item => `
<tr>
<td>${formatDateTime(item.created_at)}</td>
<td>${formatLargeNumber(item.amount)}</td>
<td>${item.currency}</td>
<td>${formatLargeNumber(item.total_notes)}</td>
<td>${formatLargeNumber(item.total_coins)}</td>
<td>${formatLargeNumber(item.total_denominations)}</td>
<td>${item.optimization_mode}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="footer">
<p>${t('history.reportTitle')}</p>
<p>${t('history.pageNumber')} ${currentPage} ${history.has_more ? t('history.morePagesAvailable') : ''}</p>
</div>
</body>
</html>
`;
};
const generateWordHTML = (): string => {
if (!history) return '';
return `
<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>
<head>
<meta charset='utf-8'>
<title>${t('history.title')}</title>
<style>
body { font-family: Calibri, Arial, sans-serif; }
h1 { color: #1f2937; border-bottom: 3px solid #3b82f6; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background-color: #f3f4f6; padding: 10px; text-align: left; font-weight: bold; border: 1px solid #000; }
td { padding: 8px; border: 1px solid #000; }
.meta { color: #666; margin: 15px 0; }
</style>
</head>
<body>
<h1>${t('history.title')}</h1>
<div class="meta">
<p><strong>${t('history.generated')}:</strong> ${formatDateTime(new Date().toISOString())}</p>
<p><strong>${t('history.total')}:</strong> ${history.total}</p>
${filterCurrency ? `<p><strong>${t('history.filteredBy')}:</strong> ${filterCurrency}</p>` : ''}
</div>
<table>
<thead>
<tr>
<th>${t('history.dateTime')}</th>
<th>${t('history.amount')}</th>
<th>${t('history.currency')}</th>
<th>${t('history.notes')}</th>
<th>${t('history.coins')}</th>
<th>${t('history.total')}</th>
<th>${t('history.mode')}</th>
</tr>
</thead>
<tbody>
${history.items.map(item => `
<tr>
<td>${formatDateTime(item.created_at)}</td>
<td>${formatLargeNumber(item.amount)}</td>
<td>${item.currency}</td>
<td>${formatLargeNumber(item.total_notes)}</td>
<td>${formatLargeNumber(item.total_coins)}</td>
<td>${formatLargeNumber(item.total_denominations)}</td>
<td>${item.optimization_mode}</td>
</tr>
`).join('')}
</tbody>
</table>
<p style="margin-top: 30px; color: #999; font-size: 11px; text-align: center;">
${t('history.reportTitle')} - ${t('history.generated')} ${formatDateTime(new Date().toISOString())}
</p>
</body>
</html>
`;
};
const handleViewDetail = async (id: number) => {
try {
const detail = await api.getCalculationDetail(id);
const amountNum = parseFloat(detail.amount);
const amountWords = numberToWords(amountNum, detail.currency);
const notesWords = numberToWords(detail.total_notes, detail.currency);
const coinsWords = numberToWords(detail.total_coins, detail.currency);
const message = `Calculation #${id}\n\nAmount: ${detail.amount} ${detail.currency}\n(${amountWords})\n\nNotes: ${detail.total_notes}\n(${notesWords})\n\nCoins: ${detail.total_coins}\n(${coinsWords})\n\nTotal Denominations: ${detail.total_denominations}`;
alert(message);
} catch (err: any) {
alert('Failed to load details: ' + err.message);
}
};
if (loading && !history) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 dark:text-blue-400" />
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400" />
<span className="text-red-700 dark:text-red-400">{error}</span>
</div>
);
}
return (
<div className="space-y-4">
{/* Header & Actions */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{t('history.title')}</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{history?.total || 0} {t('history.totalCalculations')}
</p>
</div>
<div className="flex items-center gap-2">
<select
value={filterCurrency}
onChange={(e) => {
setFilterCurrency(e.target.value);
setCurrentPage(1);
}}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 outline-none bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="">{t('history.allCurrencies')}</option>
<option value="INR">INR</option>
<option value="USD">USD</option>
<option value="EUR">EUR</option>
<option value="GBP">GBP</option>
</select>
{/* Copy Success Message */}
{copySuccess && (
<div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 font-medium bg-green-50 dark:bg-green-900/20 px-3 py-2 rounded-lg">
<Check className="w-4 h-4" />
{t('results.copiedToClipboard')}
</div>
)}
{/* Copy All Button */}
{history && history.items.length > 0 && (
<button
onClick={handleCopyAll}
className="px-4 py-2 bg-green-600 hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600 text-white rounded-lg flex items-center gap-2 text-sm"
title={t('history.copyAll')}
>
<Copy className="w-4 h-4" />
{t('history.copyAll')}
</button>
)}
{/* Delete All Button */}
{history && history.items.length > 0 && (
<button
onClick={handleDeleteAll}
className="px-4 py-2 bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 text-white rounded-lg flex items-center gap-2 text-sm"
title={t('history.deleteAll')}
>
<Trash2 className="w-4 h-4" />
{t('history.deleteAll')}
</button>
)}
{/* Export All History Dropdown */}
{history && history.items.length > 0 && (
<div className="relative">
<button
onClick={() => setShowExportMenu(!showExportMenu)}
disabled={history.items.length === 0}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white rounded-lg flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed text-sm"
>
<Download className="w-4 h-4" />
{t('history.exportAll')}
<ChevronDown className={`w-4 h-4 transition-transform ${showExportMenu ? 'rotate-180' : ''}`} />
</button>
{/* Export Dropdown Menu */}
{showExportMenu && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-10"
onClick={() => setShowExportMenu(false)}
></div>
{/* Menu */}
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 z-20 overflow-hidden">
<button
onClick={() => {
handleExport(false);
setShowExportMenu(false);
}}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 text-gray-700 dark:text-gray-300 transition-colors"
>
<FileText className="w-4 h-4 text-green-600 dark:text-green-400" />
<div>
<div className="font-medium">{t('results.exportCSV')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('results.spreadsheetFormat')}</div>
</div>
</button>
<button
onClick={handleExportPDF}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 text-gray-700 dark:text-gray-300 transition-colors border-t border-gray-100 dark:border-gray-700"
>
<FileText className="w-4 h-4 text-red-600 dark:text-red-400" />
<div>
<div className="font-medium">{t('results.exportPDF')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('results.portableDocument')}</div>
</div>
</button>
<button
onClick={handleExportWord}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 text-gray-700 dark:text-gray-300 transition-colors border-t border-gray-100 dark:border-gray-700"
>
<FileText className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div>
<div className="font-medium">{t('results.exportWord')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('results.wordFormat')}</div>
</div>
</button>
<button
onClick={handlePrint}
className="w-full px-4 py-3 text-left hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center gap-3 text-gray-700 dark:text-gray-300 transition-colors border-t border-gray-100 dark:border-gray-700"
>
<Printer className="w-4 h-4 text-gray-600 dark:text-gray-400" />
<div>
<div className="font-medium">{t('results.print')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('results.printPreview')}</div>
</div>
</button>
</div>
</>
)}
</div>
)}
</div>
</div>
{/* Bulk Actions */}
{selectedIds.size > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 flex items-center justify-between">
<span className="text-sm text-blue-700 dark:text-blue-400 font-medium">
{selectedIds.size} {t('history.selected')}
</span>
<div className="flex gap-2">
<button
onClick={handleCopySelected}
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-600 text-white text-sm rounded-lg flex items-center gap-1"
>
<Copy className="w-4 h-4" />
{t('history.copySelected')}
</button>
<button
onClick={() => handleExport(true)}
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white text-sm rounded-lg flex items-center gap-1"
>
<Download className="w-4 h-4" />
{t('history.exportSelected')}
</button>
<button
onClick={handleBulkDelete}
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 text-white text-sm rounded-lg flex items-center gap-1"
>
<Trash2 className="w-4 h-4" />
{t('history.deleteSelected')}
</button>
</div>
</div>
)}
</div>
{/* History Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
<tr>
<th className="px-4 py-3 text-left">
<button
onClick={toggleSelectAll}
className="flex items-center justify-center"
>
{selectedIds.size === history?.items.length && history?.items.length > 0 ? (
<CheckSquare className="w-5 h-5 text-blue-600 dark:text-blue-400" />
) : (
<Square className="w-5 h-5 text-gray-400 dark:text-gray-500" />
)}
</button>
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">{t('history.date')}</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">{t('history.amount')}</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">{t('history.currency')}</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">{t('history.notes')}</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">{t('history.coins')}</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-700 dark:text-gray-300">{t('history.total')}</th>
<th className="px-4 py-3 text-right text-sm font-medium text-gray-700 dark:text-gray-300">{t('history.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{history?.items.map((item) => (
<tr key={item.id} className="hover:bg-gray-50 dark:hover:bg-gray-700">
<td className="px-4 py-3">
<button
onClick={() => toggleSelect(item.id)}
className="flex items-center justify-center"
>
{selectedIds.has(item.id) ? (
<CheckSquare className="w-5 h-5 text-blue-600 dark:text-blue-400" />
) : (
<Square className="w-5 h-5 text-gray-400 dark:text-gray-500" />
)}
</button>
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
{formatDateTime(item.created_at)}
</td>
<td className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100 max-w-xs truncate" title={item.amount}>
{formatLargeNumber(item.amount)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{item.currency}</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300 max-w-xs truncate" title={item.total_notes.toString()}>
{formatLargeNumber(item.total_notes)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300 max-w-xs truncate" title={item.total_coins.toString()}>
{formatLargeNumber(item.total_coins)}
</td>
<td className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300 max-w-xs truncate" title={item.total_denominations.toString()}>
{formatLargeNumber(item.total_denominations)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleViewDetail(item.id)}
className="p-1.5 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/30 rounded-lg"
title={t('history.viewDetails')}
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(item.id)}
className="p-1.5 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg"
title={t('history.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Empty State */}
{history?.items.length === 0 && (
<div className="text-center py-12 text-gray-500 dark:text-gray-400">
<p className="text-lg font-medium">{t('history.noHistory')}</p>
<p className="text-sm">{t('history.startCalculating')}</p>
</div>
)}
{/* Pagination */}
{history && history.total > 0 && (
<div className="border-t border-gray-200 dark:border-gray-700 px-4 py-3 flex items-center justify-between">
<div className="text-sm text-gray-600 dark:text-gray-400">
{t('history.showing')} {((currentPage - 1) * 50) + 1} - {Math.min(currentPage * 50, history.total)} {t('history.of')} {history.total}
</div>
<div className="flex gap-2">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
{t('history.previous')}
</button>
<button
onClick={() => setCurrentPage(p => p + 1)}
disabled={!history.has_more}
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg text-sm disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50 dark:hover:bg-gray-700 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
{t('history.next')}
</button>
</div>
</div>
)}
</div>
</div>
);
};
?? packages\desktop-app\src\components\Layout.tsx
plaintext
import React, { useState, useEffect } from 'react';
import { Calculator, History, Upload, Settings, Moon, Sun } from 'lucide-react';
import { api } from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
interface LayoutProps {
children: React.ReactNode;
activeTab: 'calculator' | 'history' | 'bulkUpload' | 'settings';
onTabChange: (tab: 'calculator' | 'history' | 'bulkUpload' | 'settings') => void;
}
export const Layout: React.FC<LayoutProps> = ({ children, activeTab, onTabChange }) => {
const { t } = useLanguage();
const [isDarkMode, setIsDarkMode] = useState(
document.documentElement.classList.contains('dark')
);
useEffect(() => {
// Apply theme to document
if (isDarkMode) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [isDarkMode]);
useEffect(() => {
// Load theme from backend on mount
const loadTheme = async () => {
try {
const response = await api.getSetting('theme');
if (response.exists && response.value) {
setIsDarkMode(response.value === 'dark');
}
} catch (error) {
console.error('Failed to load theme:', error);
}
};
loadTheme();
}, []);
const handleThemeToggle = async () => {
const newTheme = isDarkMode ? 'light' : 'dark';
setIsDarkMode(!isDarkMode);
// Save to backend
try {
await api.updateSetting('theme', newTheme);
} catch (error) {
console.error('Failed to save theme:', error);
}
};
return (
<div className="flex h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans overflow-hidden">
{/* Sidebar */}
<aside className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div className="p-6 border-b border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="bg-blue-600 dark:bg-blue-500 p-2 rounded-lg">
<Calculator className="w-6 h-6 text-white" />
</div>
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-100">{t('app.title')}</h1>
</div>
</div>
<nav className="flex-1 p-4 space-y-2">
<button
onClick={() => onTabChange('calculator')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
activeTab === 'calculator'
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Calculator className="w-5 h-5" />
{t('nav.calculator')}
</button>
<button
onClick={() => onTabChange('history')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
activeTab === 'history'
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<History className="w-5 h-5" />
{t('nav.history')}
</button>
<button
onClick={() => onTabChange('bulkUpload')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
activeTab === 'bulkUpload'
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Upload className="w-5 h-5" />
{t('nav.bulkUpload')}
</button>
<button
onClick={() => onTabChange('settings')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
activeTab === 'settings'
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 font-medium'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<Settings className="w-5 h-5" />
{t('nav.settings')}
</button>
</nav>
<div className="p-4 border-t border-gray-100 dark:border-gray-700">
<div className="text-xs text-gray-400 dark:text-gray-500 text-center">
v1.0.0 Connected to Local Backend
</div>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col h-full overflow-hidden">
<header className="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-8">
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100">
{t(av.${activeTab}`)}
</h2>
<div className="flex items-center gap-4">
<button
onClick={handleThemeToggle}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
title={isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{isDarkMode ? (
<Sun className="w-5 h-5 text-amber-500" />
) : (
<Moon className="w-5 h-5 text-gray-600" />
)}
</button>
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span className="text-sm text-gray-500 dark:text-gray-400">System Online</span>
</div>
</div>
</header>
<div className="flex-1 overflow-auto p-8">
<div className="max-w-5xl mx-auto">
{children}
</div>
</div>
</main>
</div>
);
};
?? packages\desktop-app\src\components\QuickAccess.tsx
plaintext
import { useState, useEffect } from 'react';
import { Clock, Eye, RefreshCw } from 'lucide-react';
import { api, HistoryItem } from '../services/api';
import { formatRelativeTime } from '../utils/dateFormatter';
import { useLanguage } from '../contexts/LanguageContext';
interface QuickAccessProps {
onViewDetail: (id: number) => void;
}
export const QuickAccess: React.FC<QuickAccessProps> = ({ onViewDetail }) => {
const { t } = useLanguage();
const [items, setItems] = useState<HistoryItem[]>([]);
const [loading, setLoading] = useState(true);
const [quickAccessCount, setQuickAccessCount] = useState(10);
useEffect(() => {
loadQuickAccess();
// Poll for count changes every 2 seconds (when component is visible)
const interval = setInterval(() => {
checkCountUpdate();
}, 2000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
// Reload items when count changes
if (quickAccessCount > 0) {
loadItems(quickAccessCount);
}
}, [quickAccessCount]);
const checkCountUpdate = async () => {
try {
const settingResponse = await api.getSetting('quick_access_count');
const count = settingResponse.exists ? settingResponse.value : 10;
if (count !== quickAccessCount) {
setQuickAccessCount(count);
}
} catch (error) {
console.error('Failed to check count update:', error);
}
};
const loadQuickAccess = async () => {
try {
setLoading(true);
// Load quick access count setting
const settingResponse = await api.getSetting('quick_access_count');
const count = settingResponse.exists ? settingResponse.value : 10;
setQuickAccessCount(count);
// Load quick access items
await loadItems(count);
} catch (error) {
console.error('Failed to load quick access:', error);
} finally {
setLoading(false);
}
};
const loadItems = async (count: number) => {
try {
const response = await api.getQuickAccess(count);
setItems(response.items);
} catch (error) {
console.error('Failed to load quick access items:', error);
}
};
const formatAmount = (amount: string): string => {
const num = parseFloat(amount);
if (num >= 1e6) {
return (num / 1e6).toFixed(1) + 'M';
} else if (num >= 1e3) {
return (num / 1e3).toFixed(1) + 'K';
}
return amount;
};
if (loading) {
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-blue-600 dark:text-blue-400" />
</div>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="font-semibold text-gray-800 dark:text-gray-100">{t('quickAccess.title')}</h3>
</div>
<button
onClick={loadQuickAccess}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
title={t('quickAccess.refresh')}
>
<RefreshCw className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700 max-h-[500px] overflow-y-auto">
{items.length === 0 ? (
<div className="p-6 text-center text-gray-500 dark:text-gray-400 text-sm">
{t('quickAccess.noItems')}
</div>
) : (
items.map((item) => (
<button
key={item.id}
onClick={() => onViewDetail(item.id)}
className="w-full px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-left group"
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900 dark:text-gray-100 truncate">
{formatAmount(item.amount)} {item.currency}
</span>
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 text-xs rounded-full flex-shrink-0">
{item.currency}
</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{t('quickAccess.notesCount', { count: item.total_notes })} {t('quickAccess.coinsCount', { count: item.total_coins })}
</div>
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
{formatRelativeTime(item.created_at)}
</div>
</div>
<Eye className="w-4 h-4 text-gray-400 dark:text-gray-500 group-hover:text-blue-600 dark:group-hover:text-blue-400 flex-shrink-0 mt-1 opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</button>
))
)}
</div>
</div>
);
};
?? packages\desktop-app\src\components\ResultsDisplay.tsx
plaintext
import React, { useState } from 'react';
import { Banknote, Coins, Download, FileText, Printer, FileSpreadsheet, Copy, Check } from 'lucide-react';
import { CalculationResult } from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
interface ResultsDisplayProps {
result: CalculationResult | null;
}
// Helper function to format large numbers
const formatLargeNumber = (value: string | number): string => {
const numStr = value.toString();
const num = parseFloat(numStr);
// For very large numbers (>= 1 billion), use compact notation
if (num >= 1e15) {
return num.toExponential(2);
} else if (num >= 1e12) {
return (num / 1e12).toFixed(2) + 'T';
} else if (num >= 1e9) {
return (num / 1e9).toFixed(2) + 'B';
} else if (num >= 1e6) {
return (num / 1e6).toFixed(2) + 'M';
} else if (num >= 1e3) {
return num.toLocaleString();
}
return numStr;
};
// Helper function to convert number to words with multi-currency support
const numberToWords = (num: number, currency: string = 'INR'): string => {
if (num === 0) return 'zero';
if (num >= 1e15) return formatLargeNumber(num); // Too large for words
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine'];
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
const teens = ['ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
const convertLessThanThousand = (n: number): string => {
if (n === 0) return '';
if (n < 10) return ones[n];
if (n < 20) return teens[n - 10];
if (n < 100) {
const ten = Math.floor(n / 10);
const one = n % 10;
return tens[ten] + (one ? ' ' + ones[one] : '');
}
const hundred = Math.floor(n / 100);
const rest = n % 100;
return ones[hundred] + ' hundred' + (rest ? ' ' + convertLessThanThousand(rest) : '');
};
// Indian numbering system for INR
if (currency === 'INR') {
const crore = Math.floor(num / 10000000);
const lakh = Math.floor((num % 10000000) / 100000);
const thousand = Math.floor((num % 100000) / 1000);
const remainder = num % 1000;
let words = '';
if (crore > 0) words += convertLessThanThousand(crore) + ' crore ';
if (lakh > 0) words += convertLessThanThousand(lakh) + ' lakh ';
if (thousand > 0) words += convertLessThanThousand(thousand) + ' thousand ';
if (remainder > 0) words += convertLessThanThousand(remainder);
return words.trim();
}
// Western/International numbering system (USD, EUR, GBP, etc.)
const billion = Math.floor(num / 1000000000);
const million = Math.floor((num % 1000000000) / 1000000);
const thousand = Math.floor((num % 1000000) / 1000);
const remainder = num % 1000;
let words = '';
if (billion > 0) words += convertLessThanThousand(billion) + ' billion ';
if (million > 0) words += convertLessThanThousand(million) + ' million ';
if (thousand > 0) words += convertLessThanThousand(thousand) + ' thousand ';
if (remainder > 0) words += convertLessThanThousand(remainder);
return words.trim();
};
export const ResultsDisplay: React.FC<ResultsDisplayProps> = ({ result }) => {
const { t } = useLanguage();
const [showExportMenu, setShowExportMenu] = useState(false);
const [showCopyMenu, setShowCopyMenu] = useState(false);
const [copySuccess, setCopySuccess] = useState(false);
// Copy to clipboard as text
const handleCopyAsText = async () => {
if (!result) return;
try {
const textContent = generateTextContent(result);
await navigator.clipboard.writeText(textContent);
setCopySuccess(true);
setShowCopyMenu(false);
setTimeout(() => setCopySuccess(false), 3000);
} catch (error) {
console.error('Failed to copy:', error);
alert(t('results.copyFailed'));
}
};
// Copy to clipboard as JSON
const handleCopyAsJSON = async () => {
if (!result) return;
try {
const jsonContent = JSON.stringify(result, null, 2);
await navigator.clipboard.writeText(jsonContent);
setCopySuccess(true);
setShowCopyMenu(false);
setTimeout(() => setCopySuccess(false), 3000);
} catch (error) {
console.error('Failed to copy:', error);
alert(t('results.copyFailed'));
}
};
// Generate text content for copying
const generateTextContent = (data: CalculationResult): string => {
let text = `${t('results.title')}\n`;
text += `${'='.repeat(50)}\n\n`;
text += `${t('results.totalAmount')}: ${formatLargeNumber(data.amount)} ${data.currency}\n`;
text += `${t('results.totalNotes')}: ${formatLargeNumber(data.total_notes)}\n`;
text += `${t('results.totalCoins')}: ${formatLargeNumber(data.total_coins)}\n`;
text += `${t('results.totalDenominations')}: ${formatLargeNumber(data.total_denominations)}\n\n`;
text += `${t('results.breakdown')}:\n`;
text += `${'-'.repeat(50)}\n`;
data.breakdowns.forEach(item => {
const type = item.is_note ? t('results.note') : t('results.coin');
text += `${parseFloat(item.denomination)} (${type}): ${formatLargeNumber(item.count)} ${formatLargeNumber(item.total_value)}\n`;
});
text += `${'-'.repeat(50)}\n`;
text += `${t('results.total')}: ${formatLargeNumber(data.amount)} ${data.currency}\n`;
return text;
};
// Export to CSV
const handleExportCSV = () => {
if (!result) return;
try {
const headers = [t('results.denomination'), t('results.type'), t('results.count'), t('results.totalValue')];
const rows = result.breakdowns.map(item => [
item.denomination,
item.is_note ? t('results.note') : t('results.coin'),
item.count.toString(),
item.total_value
]);
// Add summary row
rows.push([t('results.total'), '', (result.total_notes + result.total_coins).toString(), result.amount]);
const csvContent = [
`${t('results.title')} - ${result.amount} ${result.currency}`,
`${t('history.date')}: ${new Date().toLocaleString()}`,
'',
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `breakdown_${result.currency}_${result.amount}_${Date.now()}.csv`;
link.click();
URL.revokeObjectURL(link.href);
setShowExportMenu(false);
} catch (error) {
console.error('Failed to export CSV:', error);
alert('Failed to export CSV. Please try again.');
}
};
// Export to PDF (using browser print to PDF)
const handleExportPDF = () => {
if (!result) return;
try {
const printContent = generatePrintableHTML(result);
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(printContent);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
setShowExportMenu(false);
}, 250);
} else {
alert('Please allow popups to export to PDF');
}
} catch (error) {
console.error('Failed to export PDF:', error);
alert('Failed to export PDF. Please try again.');
}
};
// Export to Word (using HTML format that Word can open)
const handleExportWord = () => {
if (!result) return;
try {
const wordContent = generateWordHTML(result);
const blob = new Blob([wordContent], { type: 'application/msword;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `breakdown_${result.currency}_${result.amount}_${Date.now()}.doc`;
link.click();
URL.revokeObjectURL(link.href);
setShowExportMenu(false);
} catch (error) {
console.error('Failed to export Word:', error);
alert('Failed to export to Word. Please try again.');
}
};
// Print
const handlePrint = () => {
if (!result) return;
try {
const printContent = generatePrintableHTML(result);
const printWindow = window.open('', '_blank');
if (printWindow) {
printWindow.document.write(printContent);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
setShowExportMenu(false);
}, 250);
} else {
alert('Please allow popups to print');
}
} catch (error) {
console.error('Failed to print:', error);
alert('Failed to print. Please try again.');
}
};
// Generate printable HTML
const generatePrintableHTML = (data: CalculationResult): string => {
return `
<!DOCTYPE html>
<html>
<head>
<title>${t('results.title')} - ${data.amount} ${data.currency}</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
h1 { color: #333; border-bottom: 2px solid #2563eb; padding-bottom: 10px; }
.meta { color: #666; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border: 1px solid #ddd; }
th { background-color: #f3f4f6; font-weight: 600; }
tr:nth-child(even) { background-color: #f9fafb; }
.text-right { text-align: right; }
.note { background-color: #dbeafe; color: #1e40af; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
.coin { background-color: #fef3c7; color: #92400e; padding: 4px 8px; border-radius: 4px; font-size: 12px; }
tfoot { font-weight: bold; background-color: #e5e7eb; }
@media print {
body { margin: 20px; }
@page { margin: 1cm; }
}
</style>
</head>
<body>
<h1>${t('results.title')}</h1>
<div class="meta">
<p><strong>${t('results.totalAmount')}:</strong> ${formatLargeNumber(data.amount)} ${data.currency}</p>
<p><strong>${t('results.totalNotes')}:</strong> ${formatLargeNumber(data.total_notes)}</p>
<p><strong>${t('results.totalCoins')}:</strong> ${formatLargeNumber(data.total_coins)}</p>
<p><strong>${t('history.date')}:</strong> ${new Date().toLocaleString()}</p>
</div>
<table>
<thead>
<tr>
<th>${t('results.denomination')}</th>
<th>${t('results.type')}</th>
<th class="text-right">${t('results.count')}</th>
<th class="text-right">${t('results.totalValue')}</th>
</tr>
</thead>
<tbody>
${data.breakdowns.map(item => `
<tr>
<td>${parseFloat(item.denomination).toLocaleString()}</td>
<td><span class="${item.is_note ? 'note' : 'coin'}">${item.is_note ? t('results.note') : t('results.coin')}</span></td>
<td class="text-right">${formatLargeNumber(item.count)}</td>
<td class="text-right">${formatLargeNumber(item.total_value)}</td>
</tr>
`).join('')}
</tbody>
<tfoot>
<tr>
<td colspan="2">${t('results.total')}</td>
<td class="text-right">${formatLargeNumber(data.total_notes + data.total_coins)}</td>
<td class="text-right">${formatLargeNumber(data.amount)}</td>
</tr>
</tfoot>
</table>
</body>
</html>
`;
};
// Generate Word-compatible HTML
const generateWordHTML = (data: CalculationResult): string => {
return `
<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>
<head>
<meta charset='utf-8'>
<title>${t('results.title')}</title>
<style>
body { font-family: Calibri, Arial, sans-serif; }
h1 { color: #2563eb; border-bottom: 2px solid #2563eb; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 10px; border: 1px solid #ddd; }
th { background-color: #f3f4f6; font-weight: bold; }
.text-right { text-align: right; }
</style>
</head>
<body>
<h1>${t('results.title')}</h1>
<p><strong>${t('results.totalAmount')}:</strong> ${formatLargeNumber(data.amount)} ${data.currency}</p>
<p><strong>${t('results.totalNotes')}:</strong> ${formatLargeNumber(data.total_notes)}</p>
<p><strong>${t('results.totalCoins')}:</strong> ${formatLargeNumber(data.total_coins)}</p>
<p><strong>${t('history.date')}:</strong> ${new Date().toLocaleString()}</p>
<table>
<thead>
<tr>
<th>${t('results.denomination')}</th>
<th>${t('results.type')}</th>
<th>${t('results.count')}</th>
<th>${t('results.totalValue')}</th>
</tr>
</thead>
<tbody>
${data.breakdowns.map(item => `
<tr>
<td>${parseFloat(item.denomination).toLocaleString()}</td>
<td>${item.is_note ? t('results.note') : t('results.coin')}</td>
<td class="text-right">${formatLargeNumber(item.count)}</td>
<td class="text-right">${formatLargeNumber(item.total_value)}</td>
</tr>
`).join('')}
</tbody>
<tfoot>
<tr style="font-weight: bold; background-color: #f3f4f6;">
<td colspan="2">${t('results.total')}</td>
<td class="text-right">${formatLargeNumber(data.total_notes + data.total_coins)}</td>
<td class="text-right">${formatLargeNumber(data.amount)}</td>
</tr>
</tfoot>
</table>
</body>
</html>
`;
};
if (!result) {
return (
<div className="h-full flex flex-col items-center justify-center text-gray-400 dark:text-gray-500 p-8 border-2 border-dashed border-gray-200 dark:border-gray-700 rounded-xl bg-gray-50 dark:bg-gray-800">
<Banknote className="w-16 h-16 mb-4 opacity-20" />
<p className="text-lg font-medium">{t('results.noResults')}</p>
<p className="text-sm">{t('results.calculate')}</p>
</div>
);
}
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">{t('results.totalAmount')}</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 break-all" title={`${result.amount} ${result.currency}`}>
{formatLargeNumber(result.amount)} {result.currency}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 italic capitalize">
{numberToWords(parseFloat(result.amount), result.currency)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">{t('results.totalNotes')}</div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400 flex items-center gap-2" title={result.total_notes.toString()}>
<Banknote className="w-6 h-6 flex-shrink-0" />
<span className="break-all">{formatLargeNumber(result.total_notes)}</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 italic capitalize">
{numberToWords(result.total_notes, result.currency)}
</div>
</div>
<div className="bg-white dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
<div className="text-sm text-gray-500 dark:text-gray-400 mb-1">{t('results.totalCoins')}</div>
<div className="text-2xl font-bold text-amber-600 dark:text-amber-400 flex items-center gap-2" title={result.total_coins.toString()}>
<Coins className="w-6 h-6 flex-shrink-0" />
<span className="break-all">{formatLargeNumber(result.total_coins)}</span>
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1 italic capitalize">
{numberToWords(result.total_coins, result.currency)}
</div>
</div>
</div>
{/* Breakdown Table */}
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100 dark:border-gray-700 flex justify-between items-center">
<h3 className="font-semibold text-gray-800 dark:text-gray-100">{t('results.title')}</h3>
<div className="flex items-center gap-2">
{/* Copy Success Message */}
{copySuccess && (
<div className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400 font-medium">
<Check className="w-4 h-4" />
{t('results.copiedToClipboard')}
</div>
)}
{/* Copy Dropdown */}
<div className="relative">
<button
onClick={() => setShowCopyMenu(!showCopyMenu)}
className="text-sm text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 font-medium flex items-center gap-1 px-3 py-1.5 rounded-lg hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
>
<Copy className="w-4 h-4" />
{t('results.copy')}
</button>
{showCopyMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowCopyMenu(false)}
/>
<div className="absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-20">
<button
onClick={handleCopyAsText}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3 text-gray-700 dark:text-gray-300 transition-colors"
>
<FileText className="w-4 h-4 text-green-600 dark:text-green-400" />
<div>
<div className="font-medium">{t('results.copyAsText')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('results.textFormat')}</div>
</div>
</button>
<button
onClick={handleCopyAsJSON}
className="w-full px-4 py-3 text-left hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-3 text-gray-700 dark:text-gray-300 transition-colors border-t border-gray-100 dark:border-gray-700"
>
<FileText className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<div>
<div className="font-medium">{t('results.copyAsJSON')}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t('results.jsonFormat')}</div>
</div>
</button>
</div>
</>
)}
</div>
{/* Export Dropdown */}
<div className="relative">
<button
onClick={() => setShowExportMenu(!showExportMenu)}
className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium flex items-center gap-1 px-3 py-1.5 rounded-lg hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
>
<Download className="w-4 h-4" />
{t('results.export')}
</button>
{showExportMenu && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowExportMenu(false)}
/>
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-20">
<button
onClick={handleExportCSV}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FileSpreadsheet className="w-4 h-4" />
{t('results.exportCSV')}
</button>
<button
onClick={handleExportPDF}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FileText className="w-4 h-4" />
{t('results.exportPDF')}
</button>
<button
onClick={handleExportWord}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<FileText className="w-4 h-4" />
{t('results.exportWord')}
</button>
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
<button
onClick={handlePrint}
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center gap-2"
>
<Printer className="w-4 h-4" />
{t('results.print')}
</button>
</div>
</>
)}
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr className="bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-400 text-sm">
<th className="px-6 py-3 font-medium">{t('results.denomination')}</th>
<th className="px-6 py-3 font-medium">{t('results.type')}</th>
<th className="px-6 py-3 font-medium text-right">{t('results.count')}</th>
<th className="px-6 py-3 font-medium text-right">{t('results.totalValue')}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{result.breakdowns.map((item, index) => (
<tr key={index} className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td className="px-6 py-3 font-medium text-gray-900 dark:text-gray-100">
{parseFloat(item.denomination).toLocaleString()}
</td>
<td className="px-6 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
item.is_note
? 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-400'
: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-400'
}`}>
{item.is_note ? t('results.note') : t('results.coin')}
</span>
</td>
<td className="px-6 py-3 text-right font-mono text-gray-600 dark:text-gray-300 max-w-xs truncate" title={item.count.toString()}>
{formatLargeNumber(item.count)}
</td>
<td className="px-6 py-3 text-right font-mono font-medium text-gray-900 dark:text-gray-100 max-w-xs truncate" title={item.total_value}>
{formatLargeNumber(item.total_value)}
</td>
</tr>
))}
</tbody>
<tfoot className="bg-gray-50 dark:bg-gray-900 font-semibold text-gray-900 dark:text-gray-100">
<tr>
<td colSpan={2} className="px-6 py-3">{t('results.total')}</td>
<td className="px-6 py-3 text-right font-mono max-w-xs truncate" title={(result.total_notes + result.total_coins).toString()}>
{formatLargeNumber(result.total_notes + result.total_coins)}
</td>
<td className="px-6 py-3 text-right font-mono max-w-xs truncate" title={result.amount}>
{formatLargeNumber(result.amount)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
);
};
?? packages\desktop-app\src\components\SettingsPage.tsx
plaintext
import React, { useState, useEffect } from 'react';
import { Settings as SettingsIcon, Moon, Sun, Globe, Zap, RefreshCw, Save, Database, Download, Star } from 'lucide-react';
import { api } from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
interface Settings {
theme: string;
default_currency: string;
default_optimization_mode: string;
quick_access_count: number;
quick_access_enabled: boolean;
auto_save_history: boolean;
sync_enabled: boolean;
language: string;
}
interface SettingsPageProps {
onSettingsChange?: () => void;
}
export const SettingsPage: React.FC<SettingsPageProps> = ({ onSettingsChange }) => {
const { language, setLanguage, supportedLanguages, t } = useLanguage();
const [settings, setSettings] = useState<Settings>({
theme: 'light',
default_currency: 'INR',
default_optimization_mode: 'greedy',
quick_access_count: 10,
quick_access_enabled: true,
auto_save_history: true,
sync_enabled: true,
language: 'en'
});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [isDarkMode, setIsDarkMode] = useState(
// Check current theme from document on initial load
document.documentElement.classList.contains('dark')
);
useEffect(() => {
loadSettings();
}, []);
useEffect(() => {
// Apply theme to document and save to localStorage as backup
if (isDarkMode) {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
}
}, [isDarkMode]);
const loadSettings = async () => {
try {
setLoading(true);
const response = await api.getSettings();
if (response.settings) {
const loadedSettings = { ...settings, ...response.settings };
setSettings(loadedSettings);
// Apply theme from settings (already applied in index.html, but sync UI state)
if (loadedSettings.theme) {
setIsDarkMode(loadedSettings.theme === 'dark');
}
}
} catch (error) {
console.error('Failed to load settings:', error);
showMessage('error', t('settings.loadError'));
} finally {
setLoading(false);
}
};
const showMessage = (type: 'success' | 'error', text: string) => {
setMessage({ type, text });
setTimeout(() => setMessage(null), 3000);
};
const handleSave = async () => {
try {
setSaving(true);
// Update each setting
for (const [key, value] of Object.entries(settings)) {
await api.updateSetting(key, value);
}
showMessage('success', t('settings.saved'));
// Apply theme immediately
setIsDarkMode(settings.theme === 'dark');
// Notify parent of settings change
if (onSettingsChange) {
onSettingsChange();
}
} catch (error) {
console.error('Failed to save settings:', error);
showMessage('error', t('settings.error'));
} finally {
setSaving(false);
}
};
const handleReset = async () => {
if (!confirm(t('settings.resetConfirm'))) {
return;
}
try {
setSaving(true);
const response = await api.resetSettings();
if (response.settings) {
setSettings({ ...settings, ...response.settings });
setIsDarkMode(response.settings.theme === 'dark');
}
showMessage('success', t('settings.resetSuccess'));
// Notify parent of settings change
if (onSettingsChange) {
onSettingsChange();
}
} catch (error) {
console.error('Failed to reset settings:', error);
showMessage('error', t('settings.resetError'));
} finally {
setSaving(false);
}
};
const handleThemeToggle = () => {
const newTheme = settings.theme === 'light' ? 'dark' : 'light';
setSettings({ ...settings, theme: newTheme });
setIsDarkMode(newTheme === 'dark');
};
const handleQuickAccessToggle = async (enabled: boolean) => {
// Update local state
setSettings({ ...settings, quick_access_enabled: enabled });
// Immediately save this specific setting to backend
try {
await api.updateSetting('quick_access_enabled', enabled);
showMessage('success', t(enabled ? 'settings.quickAccessEnabled_success' : 'settings.quickAccessDisabled_success'));
// Notify parent immediately for instant UI update
if (onSettingsChange) {
onSettingsChange();
}
} catch (error) {
console.error('Failed to update quick access setting:', error);
showMessage('error', t('settings.error'));
// Revert local state on error
setSettings({ ...settings, quick_access_enabled: !enabled });
}
};
const handleQuickAccessCountChange = async (count: number) => {
// Validate range
if (count < 5 || count > 20) {
showMessage('error', t('settings.quickAccessCountError'));
return;
}
// Update local state
setSettings({ ...settings, quick_access_count: count });
// Immediately save to backend
try {
await api.updateSetting('quick_access_count', count);
showMessage('success', t('settings.quickAccessCountUpdated', { count: count.toString() }));
// Notify parent to reload the setting
if (onSettingsChange) {
onSettingsChange();
}
} catch (error) {
console.error('Failed to update quick access count:', error);
showMessage('error', t('settings.error'));
}
};
const handleDefaultCurrencyChange = async (currency: string) => {
// Update local state
setSettings({ ...settings, default_currency: currency });
// Immediately save to backend
try {
await api.updateSetting('default_currency', currency);
showMessage('success', t('settings.currencyUpdated', { currency }));
} catch (error) {
console.error('Failed to update default currency:', error);
showMessage('error', t('settings.error'));
// Revert on error
const response = await api.getSetting('default_currency');
if (response.exists) {
setSettings({ ...settings, default_currency: response.value });
}
}
};
const handleOptimizationModeChange = async (mode: string) => {
// Update local state
setSettings({ ...settings, default_optimization_mode: mode });
// Immediately save to backend
try {
await api.updateSetting('default_optimization_mode', mode);
showMessage('success', t('settings.optimizationUpdated'));
} catch (error) {
console.error('Failed to update optimization mode:', error);
showMessage('error', t('settings.error'));
// Revert on error
const response = await api.getSetting('default_optimization_mode');
if (response.exists) {
setSettings({ ...settings, default_optimization_mode: response.value });
}
}
};
const handleLanguageChange = async (newLanguage: string) => {
try {
await setLanguage(newLanguage);
setSettings({ ...settings, language: newLanguage });
showMessage('success', t('settings.languageUpdated'));
} catch (error) {
console.error('Failed to update language:', error);
showMessage('error', t('settings.error'));
}
};
const handleAutoSaveHistoryToggle = async (enabled: boolean) => {
// Update local state
setSettings({ ...settings, auto_save_history: enabled });
// Immediately save this specific setting to backend
try {
await api.updateSetting('auto_save_history', enabled);
showMessage('success', t(enabled ? 'settings.autoSaveEnabled' : 'settings.autoSaveDisabled'));
// Notify parent for any UI updates if needed
if (onSettingsChange) {
onSettingsChange();
}
} catch (error) {
console.error('Failed to update auto-save history setting:', error);
showMessage('error', t('settings.error'));
// Revert local state on error
setSettings({ ...settings, auto_save_history: !enabled });
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-8 h-8 animate-spin text-blue-600 dark:text-blue-400" />
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 px-6 py-8">
<div className="flex items-center gap-3">
<SettingsIcon className="w-8 h-8 text-white" />
<div>
<h2 className="text-2xl font-bold text-white">{t('settings.title')}</h2>
<p className="text-blue-100 text-sm mt-1">{t('settings.subtitle')}</p>
</div>
</div>
</div>
{/* Message */}
{message && (
<div className={`mx-6 mt-6 p-4 rounded-lg ${
message.type === 'success'
? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-400 border border-green-200 dark:border-green-800'
: 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-400 border border-red-200 dark:border-red-800'
}`}>
{message.text}
</div>
)}
{/* Settings Content */}
<div className="p-6 space-y-6">
{/* Appearance */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700">
<Sun className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">{t('settings.appearance')}</h3>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<label className="font-medium text-gray-700 dark:text-gray-200">{t('settings.theme')}</label>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('settings.appearanceDesc')}</p>
</div>
<button
onClick={handleThemeToggle}
className={`relative inline-flex h-10 w-20 items-center rounded-full transition-colors ${
isDarkMode ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<span
className={`inline-flex h-8 w-8 items-center justify-center transform rounded-full bg-white transition-transform ${
isDarkMode ? 'translate-x-11' : 'translate-x-1'
}`}
>
{isDarkMode ? (
<Moon className="w-4 h-4 text-blue-600" />
) : (
<Sun className="w-4 h-4 text-gray-600" />
)}
</span>
</button>
</div>
</div>
{/* Language & Region */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700">
<Globe className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">{t('settings.languageRegion')}</h3>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('settings.language')}
</label>
<select
value={language}
onChange={(e) => handleLanguageChange(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
{supportedLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
{t('settings.languageDesc')}
</p>
</div>
</div>
{/* Defaults */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700">
<Star className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">{t('settings.defaultPreferences')}</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('settings.defaultCurrency')}
</label>
<select
value={settings.default_currency}
onChange={(e) => handleDefaultCurrencyChange(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="INR">{t('currencies.INR')}</option>
<option value="USD">{t('currencies.USD')}</option>
<option value="EUR">{t('currencies.EUR')}</option>
<option value="GBP">{t('currencies.GBP')}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('settings.defaultOptimization')}
</label>
<select
value={settings.default_optimization_mode}
onChange={(e) => handleOptimizationModeChange(e.target.value)}
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="greedy">{t('calculator.greedy')}</option>
<option value="balanced">{t('calculator.balanced')}</option>
<option value="minimize_large">{t('calculator.minimizeLarge')}</option>
<option value="minimize_small">{t('calculator.minimizeSmall')}</option>
</select>
</div>
</div>
</div>
{/* Behavior */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700">
<Zap className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">{t('settings.behavior')}</h3>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<label className="font-medium text-gray-700 dark:text-gray-200">{t('settings.autoSaveHistory')}</label>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('settings.autoSaveDesc')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.auto_save_history}
onChange={(e) => handleAutoSaveHistoryToggle(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<label className="font-medium text-gray-700 dark:text-gray-200">{t('settings.quickAccessEnabled')}</label>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('settings.quickAccessDesc')}</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={settings.quick_access_enabled}
onChange={(e) => handleQuickAccessToggle(e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<label className="font-medium text-gray-700 dark:text-gray-200">{t('settings.quickAccessCount')}</label>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('settings.quickAccessCountDesc')}</p>
</div>
<input
type="number"
min="5"
max="20"
value={settings.quick_access_count}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) {
// Just update local state while typing
setSettings({ ...settings, quick_access_count: value });
}
}}
onBlur={(e) => {
const value = parseInt(e.target.value);
if (isNaN(value) || value < 5) {
handleQuickAccessCountChange(5);
} else if (value > 20) {
handleQuickAccessCountChange(20);
} else {
handleQuickAccessCountChange(value);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.currentTarget.blur(); // Trigger onBlur to save
}
}}
disabled={!settings.quick_access_enabled}
className="w-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none text-center bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
</div>
</div>
{/* Data & Sync */}
<div className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b border-gray-200 dark:border-gray-700">
<Database className="w-5 h-5 text-gray-600 dark:text-gray-400" />
<h3 className="text-lg font-semibold text-gray-800 dark:text-gray-100">{t('settings.dataSync')}</h3>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<div>
<label className="font-medium text-gray-700 dark:text-gray-200">{t('settings.syncEnabled')}</label>
<p className="text-sm text-gray-500 dark:text-gray-400">{t('settings.syncDesc')}</p>
</div>
<input
type="checkbox"
checked={settings.sync_enabled}
onChange={(e) => setSettings({ ...settings, sync_enabled: e.target.checked })}
disabled
className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500 opacity-50 cursor-not-allowed"
/>
</div>
</div>
</div>
{/* Footer Actions */}
<div className="bg-gray-50 dark:bg-gray-900 px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
<button
onClick={handleReset}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className="w-4 h-4" />
{t('settings.resetToDefaults')}
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium rounded-lg transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
>
{saving ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
{t('settings.saving')}
</>
) : (
<>
<Save className="w-4 h-4" />
{t('settings.saveChanges')}
</>
)}
</button>
</div>
</div>
{/* Additional Info Card */}
<div className="mt-6 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-3">
<Download className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
<div>
<h4 className="font-medium text-blue-900 dark:text-blue-300">{t('settings.dataStorageTitle')}</h4>
<p className="text-sm text-blue-700 dark:text-blue-400 mt-1">
{t('settings.dataStorageDesc')}
</p>
</div>
</div>
</div>
</div>
);
};
contexts/
?? packages\desktop-app\src\contexts\LanguageContext.tsx
plaintext
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { api } from '../services/api';
export interface Translations {
[key: string]: any;
}
interface LanguageContextType {
language: string;
translations: Translations;
setLanguage: (lang: string) => Promise<void>;
t: (key: string, params?: { [key: string]: string | number }) => string;
isLoading: boolean;
supportedLanguages: Array<{ code: string; name: string }>;
}
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
interface LanguageProviderProps {
children: ReactNode;
}
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
const [language, setLanguageState] = useState<string>('en');
const [translations, setTranslations] = useState<Translations>({});
const [isLoading, setIsLoading] = useState<boolean>(true);
const [supportedLanguages, setSupportedLanguages] = useState<Array<{ code: string; name: string }>>([]);
// Helper function to get nested translation by dot notation key
const getNestedTranslation = (obj: any, path: string): any => {
return path.split('.').reduce((current, key) => current?.[key], obj);
};
// Translation function with parameter replacement
const t = (key: string, params?: { [key: string]: string | number }): string => {
let translation = getNestedTranslation(translations, key);
// Fallback to key if translation not found
if (translation === undefined) {
console.warn(`Translation missing for key: ${key}`);
return key;
}
// Replace parameters in translation
if (params && typeof translation === 'string') {
Object.keys(params).forEach(paramKey => {
translation = translation.replace(`{${paramKey}}`, String(params[paramKey]));
});
}
return translation;
};
// Load translations for a specific language
const loadTranslations = async (langCode: string) => {
setIsLoading(true);
try {
const response = await api.getTranslations(langCode);
setTranslations(response.translations);
setLanguageState(langCode);
// Save language preference to backend
await api.updateSetting('language', langCode);
} catch (error) {
console.error('Failed to load translations:', error);
// Fallback to English
if (langCode !== 'en') {
await loadTranslations('en');
}
} finally {
setIsLoading(false);
}
};
// Set language and load translations
const setLanguage = async (lang: string) => {
await loadTranslations(lang);
};
// Load supported languages and initialize
useEffect(() => {
const initialize = async () => {
try {
// Load supported languages
const langResponse = await api.getSupportedLanguages();
setSupportedLanguages(langResponse.languages);
// Load saved language preference from backend
const savedLangResponse = await api.getSetting('language');
const savedLang = savedLangResponse.value || 'en';
await loadTranslations(savedLang);
} catch (error) {
console.error('Failed to initialize language:', error);
// Fallback to English
await loadTranslations('en');
}
};
initialize();
}, []);
return (
<LanguageContext.Provider
value={{
language,
translations,
setLanguage,
t,
isLoading,
supportedLanguages
}}
>
{children}
</LanguageContext.Provider>
);
};
// Custom hook to use language context
export const useLanguage = (): LanguageContextType => {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider');
}
return context;
};
hooks/
?? packages\desktop-app\src\hooks\useSmartCurrency.ts
plaintext
/**
* useSmartCurrency Hook
*
* Provides smart currency detection and recommendation throughout the app
*/
import { useState, useEffect, useCallback } from 'react';
import { smartCurrencyService, SmartCurrencyRecommendation } from '../services/smartCurrency';
import { useLanguage } from '../contexts/LanguageContext';
interface UseSmartCurrencyReturn {
recommendedCurrency: string | null;
confidence: 'high' | 'medium' | 'low' | null;
reason: string | null;
alternatives: string[];
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
recordUsage: (currency: string) => void;
}
export const useSmartCurrency = (): UseSmartCurrencyReturn => {
const { language } = useLanguage();
const [recommendation, setRecommendation] = useState<SmartCurrencyRecommendation | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const fetchRecommendation = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const result = await smartCurrencyService.getSmartCurrencyRecommendation(language);
setRecommendation(result);
} catch (err: any) {
console.error('Failed to get smart currency recommendation:', err);
setError(err.message || 'Failed to get currency recommendation');
// Fallback to USD
setRecommendation({
recommendedCurrency: 'USD',
confidence: 'low',
reason: 'Default fallback',
alternatives: ['EUR', 'GBP', 'INR'],
usageStats: [],
});
} finally {
setIsLoading(false);
}
}, [language]);
useEffect(() => {
fetchRecommendation();
}, [fetchRecommendation]);
const recordUsage = useCallback((currency: string) => {
smartCurrencyService.recordCurrencyUsage(currency);
}, []);
const refresh = useCallback(async () => {
await fetchRecommendation();
}, [fetchRecommendation]);
return {
recommendedCurrency: recommendation?.recommendedCurrency || null,
confidence: recommendation?.confidence || null,
reason: recommendation?.reason || null,
alternatives: recommendation?.alternatives || [],
isLoading,
error,
refresh,
recordUsage,
};
};
services/
?? packages\desktop-app\src\services\api.ts
plaintext
import axios from 'axios';
const API_BASE_URL = 'http://localhost:8001';
export interface CalculationRequest {
amount: number | string;
currency: string;
optimization_mode?: string;
save_to_history?: boolean;
}
export interface DenominationBreakdown {
denomination: string;
count: number;
is_note: boolean;
total_value: string;
}
export interface CalculationResult {
id?: number;
amount: string;
currency: string;
total_notes: number;
total_coins: number;
total_denominations: number;
breakdowns: DenominationBreakdown[];
optimization_mode?: string;
created_at?: string;
}
export interface HistoryItem {
id: number;
amount: string;
currency: string;
total_notes: number;
total_coins: number;
total_denominations: number;
optimization_mode: string;
source: string;
synced: boolean;
created_at: string;
}
export interface HistoryResponse {
items: HistoryItem[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}
export const api = {
calculate: async (data: CalculationRequest): Promise<CalculationResult> => {
const response = await axios.post(`${API_BASE_URL}/api/v1/calculate`, data);
return response.data;
},
getCurrencies: async () => {
const response = await axios.get(`${API_BASE_URL}/api/v1/currencies`);
return response.data;
},
getHistory: async (page = 1, pageSize = 50, currency?: string): Promise<HistoryResponse> => {
const response = await axios.get(`${API_BASE_URL}/api/v1/history`, {
params: { page, page_size: pageSize, currency }
});
return response.data;
},
getCalculationDetail: async (id: number): Promise<any> => {
const response = await axios.get(`${API_BASE_URL}/api/v1/history/${id}`);
return response.data;
},
deleteCalculation: async (id: number): Promise<void> => {
await axios.delete(`${API_BASE_URL}/api/v1/history/${id}`);
},
bulkDeleteCalculations: async (ids: number[]): Promise<void> => {
await axios.post(`${API_BASE_URL}/api/v1/history/bulk-delete`, { ids });
},
deleteAllHistory: async (currency?: string): Promise<{ deleted_count: number }> => {
const response = await axios.delete(`${API_BASE_URL}/api/v1/history`, {
params: { currency }
});
return response.data;
},
exportHistoryCSV: async (ids?: number[], currency?: string): Promise<Blob> => {
const response = await axios.post(`${API_BASE_URL}/api/v1/history/export/csv`,
{ ids, currency },
{ responseType: 'blob' }
);
return response.data;
},
// Settings endpoints
getSettings: async (): Promise<any> => {
const response = await axios.get(`${API_BASE_URL}/api/v1/settings`);
return response.data;
},
getSetting: async (key: string): Promise<any> => {
const response = await axios.get(`${API_BASE_URL}/api/v1/settings/${key}`);
return response.data;
},
updateSetting: async (key: string, value: any): Promise<any> => {
const response = await axios.put(`${API_BASE_URL}/api/v1/settings`, { key, value });
return response.data;
},
deleteSetting: async (key: string): Promise<any> => {
const response = await axios.delete(`${API_BASE_URL}/api/v1/settings/${key}`);
return response.data;
},
resetSettings: async (): Promise<any> => {
const response = await axios.post(`${API_BASE_URL}/api/v1/settings/reset`);
return response.data;
},
// Quick Access
getQuickAccess: async (count: number = 10): Promise<{ items: HistoryItem[], count: number }> => {
const response = await axios.get(`${API_BASE_URL}/api/v1/history/quick-access`, {
params: { count }
});
return response.data;
},
// Translations endpoints
getSupportedLanguages: async (): Promise<any> => {
const response = await axios.get(`${API_BASE_URL}/api/v1/translations/languages`);
return response.data;
},
getTranslations: async (languageCode: string): Promise<any> => {
const response = await axios.get(`${API_BASE_URL}/api/v1/translations/${languageCode}`);
return response.data;
},
// Bulk Upload endpoint
uploadBulkCSV: async (file: File, saveToHistory: boolean = true): Promise<any> => {
const formData = new FormData();
formData.append('file', file);
const response = await axios.post(
`${API_BASE_URL}/api/v1/bulk-upload`,
formData,
{
params: {
save_to_history: saveToHistory
},
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
return response.data;
},
// Smart Currency Recommendation
getSmartCurrencyRecommendation: async (timezone?: string, locale?: string, language?: string): Promise<any> => {
const response = await axios.get(`${API_BASE_URL}/api/v1/smart-currency`, {
params: { timezone, locale, language }
});
return response.data;
}
};
?? packages\desktop-app\src\services\smartCurrency.ts
plaintext
/**
* Smart Currency Service
*
* Provides intelligent currency detection and recommendation based on:
* - System timezone and region
* - User's currency usage patterns over time
* - Language preferences
*/
import { api } from './api';
export interface CurrencyUsageStats {
currency: string;
count: number;
lastUsed: string;
percentage: number;
}
export interface SmartCurrencyRecommendation {
recommendedCurrency: string;
confidence: 'high' | 'medium' | 'low';
reason: string;
alternatives: string[];
usageStats: CurrencyUsageStats[];
}
/**
* Timezone to Currency mapping based on common regional patterns
*/
const TIMEZONE_CURRENCY_MAP: Record<string, string> = {
// North America
'America/New_York': 'USD',
'America/Chicago': 'USD',
'America/Denver': 'USD',
'America/Los_Angeles': 'USD',
'America/Phoenix': 'USD',
'America/Anchorage': 'USD',
'America/Toronto': 'CAD',
'America/Vancouver': 'CAD',
'America/Montreal': 'CAD',
'America/Mexico_City': 'USD', // Common for business
// Europe
'Europe/London': 'GBP',
'Europe/Paris': 'EUR',
'Europe/Berlin': 'EUR',
'Europe/Rome': 'EUR',
'Europe/Madrid': 'EUR',
'Europe/Amsterdam': 'EUR',
'Europe/Brussels': 'EUR',
'Europe/Vienna': 'EUR',
'Europe/Zurich': 'EUR',
'Europe/Dublin': 'EUR',
'Europe/Lisbon': 'EUR',
'Europe/Stockholm': 'EUR',
'Europe/Oslo': 'EUR',
'Europe/Copenhagen': 'EUR',
'Europe/Helsinki': 'EUR',
'Europe/Athens': 'EUR',
'Europe/Warsaw': 'EUR',
'Europe/Prague': 'EUR',
'Europe/Budapest': 'EUR',
// Asia
'Asia/Kolkata': 'INR',
'Asia/Mumbai': 'INR',
'Asia/Delhi': 'INR',
'Asia/Bangalore': 'INR',
'Asia/Chennai': 'INR',
'Asia/Tokyo': 'JPY',
'Asia/Seoul': 'JPY',
'Asia/Shanghai': 'CNY',
'Asia/Beijing': 'CNY',
'Asia/Hong_Kong': 'CNY',
'Asia/Singapore': 'USD',
'Asia/Dubai': 'USD',
'Asia/Karachi': 'USD',
// Oceania
'Australia/Sydney': 'AUD',
'Australia/Melbourne': 'AUD',
'Australia/Brisbane': 'AUD',
'Australia/Perth': 'AUD',
'Pacific/Auckland': 'AUD',
};
/**
* Language to Currency mapping as fallback
*/
const LANGUAGE_CURRENCY_MAP: Record<string, string> = {
'en': 'USD',
'en-US': 'USD',
'en-GB': 'GBP',
'en-CA': 'CAD',
'en-AU': 'AUD',
'hi': 'INR',
'es': 'EUR',
'fr': 'EUR',
'de': 'EUR',
'ja': 'JPY',
'zh': 'CNY',
};
class SmartCurrencyService {
private usageCache: CurrencyUsageStats[] | null = null;
private cacheTimestamp: number = 0;
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
/**
* Detect system timezone
*/
private detectTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (error) {
console.error('Failed to detect timezone:', error);
return 'UTC';
}
}
/**
* Detect system locale/language
*/
private detectLocale(): string {
try {
return navigator.language || navigator.languages?.[0] || 'en-US';
} catch (error) {
console.error('Failed to detect locale:', error);
return 'en-US';
}
}
/**
* Get currency based on timezone
*/
private getCurrencyFromTimezone(timezone: string): string | null {
// Direct match
if (TIMEZONE_CURRENCY_MAP[timezone]) {
return TIMEZONE_CURRENCY_MAP[timezone];
}
// Try to match by region (e.g., "America/Unknown" -> USD)
const region = timezone.split('/')[0];
switch (region) {
case 'America':
return 'USD';
case 'Europe':
return 'EUR';
case 'Asia':
// Asia is diverse, check specific patterns
if (timezone.includes('India') || timezone.includes('Kolkata') || timezone.includes('Calcutta')) {
return 'INR';
}
if (timezone.includes('Tokyo') || timezone.includes('Japan')) {
return 'JPY';
}
if (timezone.includes('China') || timezone.includes('Shanghai') || timezone.includes('Beijing')) {
return 'CNY';
}
return 'USD'; // Default for Asia
case 'Australia':
case 'Pacific':
return 'AUD';
default:
return null;
}
}
/**
* Get currency based on locale/language
*/
private getCurrencyFromLocale(locale: string): string {
// Try exact match first
if (LANGUAGE_CURRENCY_MAP[locale]) {
return LANGUAGE_CURRENCY_MAP[locale];
}
// Try base language (e.g., "en-US" -> "en")
const baseLanguage = locale.split('-')[0];
return LANGUAGE_CURRENCY_MAP[baseLanguage] || 'USD';
}
/**
* Fetch currency usage statistics from history
*/
async getCurrencyUsageStats(forceRefresh = false): Promise<CurrencyUsageStats[]> {
const now = Date.now();
// Return cache if valid
if (!forceRefresh && this.usageCache && (now - this.cacheTimestamp) < this.CACHE_DURATION) {
return this.usageCache;
}
try {
// Fetch all history to analyze currency usage
const historyResponse = await api.getHistory(1, 1000); // Get up to 1000 records
if (!historyResponse.items || historyResponse.items.length === 0) {
this.usageCache = [];
this.cacheTimestamp = now;
return [];
}
// Count currency usage
const currencyCount: Record<string, { count: number; lastUsed: string }> = {};
historyResponse.items.forEach((item) => {
const currency = item.currency;
if (!currencyCount[currency]) {
currencyCount[currency] = { count: 0, lastUsed: item.created_at };
}
currencyCount[currency].count++;
// Track most recent usage
if (new Date(item.created_at) > new Date(currencyCount[currency].lastUsed)) {
currencyCount[currency].lastUsed = item.created_at;
}
});
// Calculate percentages and create stats array
const totalCount = historyResponse.items.length;
const stats: CurrencyUsageStats[] = Object.entries(currencyCount)
.map(([currency, data]) => ({
currency,
count: data.count,
lastUsed: data.lastUsed,
percentage: (data.count / totalCount) * 100,
}))
.sort((a, b) => b.count - a.count); // Sort by most used
this.usageCache = stats;
this.cacheTimestamp = now;
return stats;
} catch (error) {
console.error('Failed to fetch currency usage stats:', error);
return [];
}
}
/**
* Get the most frequently used currency
*/
async getMostUsedCurrency(): Promise<string | null> {
const stats = await this.getCurrencyUsageStats();
return stats.length > 0 ? stats[0].currency : null;
}
/**
* Get smart currency recommendation
*/
async getSmartCurrencyRecommendation(currentLanguage?: string): Promise<SmartCurrencyRecommendation> {
const timezone = this.detectTimezone();
const locale = this.detectLocale();
const usageStats = await this.getCurrencyUsageStats();
let recommendedCurrency: string;
let confidence: 'high' | 'medium' | 'low';
let reason: string;
const alternatives: string[] = [];
// Priority 1: User's historical usage (if significant)
if (usageStats.length > 0 && usageStats[0].count >= 3) {
// User has used a currency at least 3 times
recommendedCurrency = usageStats[0].currency;
confidence = usageStats[0].percentage >= 60 ? 'high' : 'medium';
reason = `Based on your usage history (${usageStats[0].count} calculations, ${usageStats[0].percentage.toFixed(0)}%)`;
// Add other frequently used currencies as alternatives
usageStats.slice(1, 4).forEach(stat => alternatives.push(stat.currency));
}
// Priority 2: Timezone-based detection
else {
const timezoneCurrency = this.getCurrencyFromTimezone(timezone);
if (timezoneCurrency) {
recommendedCurrency = timezoneCurrency;
confidence = 'high';
reason = `Based on your system timezone (${timezone})`;
}
// Priority 3: Language/Locale-based detection
else {
const localeCurrency = currentLanguage
? this.getCurrencyFromLocale(currentLanguage)
: this.getCurrencyFromLocale(locale);
recommendedCurrency = localeCurrency;
confidence = 'medium';
reason = `Based on your system language (${currentLanguage || locale})`;
}
// Add common alternatives based on region
const region = timezone.split('/')[0];
switch (region) {
case 'America':
alternatives.push('USD', 'CAD');
break;
case 'Europe':
alternatives.push('EUR', 'GBP');
break;
case 'Asia':
alternatives.push('INR', 'JPY', 'CNY', 'USD');
break;
case 'Australia':
case 'Pacific':
alternatives.push('AUD', 'USD');
break;
default:
alternatives.push('USD', 'EUR', 'GBP');
}
// Remove recommended currency from alternatives and deduplicate
const uniqueAlternatives = [...new Set(alternatives)].filter(c => c !== recommendedCurrency);
alternatives.length = 0;
alternatives.push(...uniqueAlternatives.slice(0, 3));
}
return {
recommendedCurrency,
confidence,
reason,
alternatives,
usageStats,
};
}
/**
* Record a currency usage (called after calculation)
*/
recordCurrencyUsage(_currency: string): void {
// Invalidate cache so next request fetches fresh data
this.usageCache = null;
}
/**
* Get system information for debugging
*/
getSystemInfo() {
return {
timezone: this.detectTimezone(),
locale: this.detectLocale(),
timestamp: new Date().toISOString(),
};
}
}
// Export singleton instance
export const smartCurrencyService = new SmartCurrencyService();
utils/
?? packages\desktop-app\src\utils\dateFormatter.ts
plaintext
/**
* Date formatting utilities
* Handles timezone-aware datetime formatting for consistent display
*/
/**
* Format a date string to local date and time
* @param dateStr ISO 8601 date string from backend (UTC)
* @returns Formatted local date and time string
*/
export const formatDateTime = (dateStr: string): string => {
const date = new Date(dateStr);
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
};
/**
* Format a date string to relative time (e.g., "5m ago", "2h ago")
* Falls back to date if older than 7 days
* @param dateStr ISO 8601 date string from backend (UTC)
* @returns Relative time string
*/
export const formatRelativeTime = (dateStr: string): string => {
const date = new Date(dateStr);
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
};
/**
* Format a date string to just the date (no time)
* @param dateStr ISO 8601 date string from backend (UTC)
* @returns Formatted local date string
*/
export const formatDate = (dateStr: string): string => {
const date = new Date(dateStr);
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
return date.toLocaleDateString();
};
/**
* Format a date string to just the time (no date)
* @param dateStr ISO 8601 date string from backend (UTC)
* @returns Formatted local time string
*/
export const formatTime = (dateStr: string): string => {
const date = new Date(dateStr);
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Invalid Time';
}
return date.toLocaleTimeString();
};
/**
* Format a date string with custom options
* @param dateStr ISO 8601 date string from backend (UTC)
* @param options Intl.DateTimeFormatOptions
* @returns Formatted date string
*/
export const formatDateCustom = (
dateStr: string,
options: Intl.DateTimeFormatOptions = {}
): string => {
const date = new Date(dateStr);
// Check if date is valid
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
return date.toLocaleString(undefined, options);
};
?? packages\desktop-app\src\App.tsx
plaintext
import { useState, useRef, useEffect } from 'react';
import { Layout } from './components/Layout';
import { CalculationForm } from './components/CalculationForm';
import { ResultsDisplay } from './components/ResultsDisplay';
import { HistoryPage } from './components/HistoryPage';
import { SettingsPage } from './components/SettingsPage';
import { QuickAccess } from './components/QuickAccess';
import { BulkUploadPage } from './components/BulkUploadPage';
import { CalculationResult, api } from './services/api';
function App() {
const [activeTab, setActiveTab] = useState<'calculator' | 'history' | 'bulkUpload' | 'settings'>('calculator');
const [currentResult, setCurrentResult] = useState<CalculationResult | null>(null);
const [quickAccessEnabled, setQuickAccessEnabled] = useState<boolean | null>(null); // null = loading
const resultsRef = useRef<HTMLDivElement>(null);
// Load quick access enabled setting on mount
useEffect(() => {
const loadQuickAccessSetting = async () => {
try {
const response = await api.getSetting('quick_access_enabled');
if (response.exists) {
setQuickAccessEnabled(response.value);
} else {
// Default to true if setting doesn't exist
setQuickAccessEnabled(true);
}
} catch (error) {
console.error('Failed to load quick access setting:', error);
// Default to true on error
setQuickAccessEnabled(true);
}
};
loadQuickAccessSetting();
}, []);
// Reload quick access setting when switching to calculator tab
useEffect(() => {
if (activeTab === 'calculator') {
const reloadQuickAccessSetting = async () => {
try {
const response = await api.getSetting('quick_access_enabled');
if (response.exists) {
setQuickAccessEnabled(response.value);
}
} catch (error) {
console.error('Failed to reload quick access setting:', error);
}
};
reloadQuickAccessSetting();
}
}, [activeTab]);
// Scroll to results when a new calculation is completed
useEffect(() => {
if (currentResult && resultsRef.current && activeTab === 'calculator') {
setTimeout(() => {
resultsRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}, 100); // Small delay to ensure DOM is updated
}
}, [currentResult, activeTab]);
const handleViewDetail = async (id: number) => {
try {
const detail = await api.getCalculationDetail(id);
// Transform backend response to CalculationResult format
const calculationResult: CalculationResult = {
id: detail.id,
amount: detail.amount,
currency: detail.currency,
total_notes: typeof detail.total_notes === 'string' ? parseInt(detail.total_notes) : detail.total_notes,
total_coins: typeof detail.total_coins === 'string' ? parseInt(detail.total_coins) : detail.total_coins,
total_denominations: typeof detail.total_denominations === 'string' ? parseInt(detail.total_denominations) : detail.total_denominations,
breakdowns: detail.result?.breakdowns || [],
optimization_mode: detail.optimization_mode,
created_at: detail.created_at
};
setCurrentResult(calculationResult);
// Scroll to results
if (resultsRef.current) {
setTimeout(() => {
resultsRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}, 100);
}
} catch (error) {
console.error('Failed to load calculation:', error);
alert('Failed to load calculation details. Please try again.');
}
};
const renderContent = () => {
switch (activeTab) {
case 'calculator':
return (
<div className="h-full flex flex-col">
{/* Hero Calculator Section */}
<div className="flex-shrink-0">
<CalculationForm onCalculationComplete={setCurrentResult} />
</div>
{/* Results & Quick Access Section */}
<div ref={resultsRef} className="flex-1 mt-6 grid grid-cols-1 xl:grid-cols-12 gap-6 min-h-0">
<div className={quickAccessEnabled ? "xl:col-span-8" : "xl:col-span-12"}>
<ResultsDisplay result={currentResult} />
</div>
{/* Only render QuickAccess if enabled and setting is loaded */}
{quickAccessEnabled === true && (
<div className="xl:col-span-4">
<QuickAccess onViewDetail={handleViewDetail} />
</div>
)}
</div>
</div>
);
case 'history':
return <HistoryPage />;
case 'bulkUpload':
return <BulkUploadPage />;
case 'settings':
return <SettingsPage onSettingsChange={async () => {
// Reload quick access setting when settings are saved
try {
const response = await api.getSetting('quick_access_enabled');
if (response.exists) {
setQuickAccessEnabled(response.value);
}
} catch (error) {
console.error('Failed to reload quick access setting:', error);
}
}} />;
default:
return null;
}
};
return (
<Layout activeTab={activeTab} onTabChange={setActiveTab}>
{renderContent()}
</Layout>
);
}
export default App;
?? packages\desktop-app\src\index.css
css
@tailwind base;
@tailwind components;
@tailwind utilities;
?? packages\desktop-app\src\main.tsx
plaintext
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import { LanguageProvider } from './contexts/LanguageContext'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<LanguageProvider>
<App />
</LanguageProvider>
</React.StrictMode>,
)
// Remove Preload scripts loading
postMessage({ payload: 'removeLoading' }, '*')
?? packages\desktop-app\src\vite-env.d.ts
plaintext
/// <reference types="vite/client" />
?? packages\desktop-app\index.html
html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Currency Denomination Distributor</title>
<!-- Load and apply theme before rendering to prevent flash -->
<script>
(async function() {
try {
// Fetch theme from backend
const response = await fetch('http://localhost:8001/api/v1/settings/theme');
if (response.ok) {
const data = await response.json();
if (data.exists && data.value === 'dark') {
document.documentElement.classList.add('dark');
}
}
} catch (error) {
// Fallback: check localStorage for theme preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark');
}
}
})();
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
?? packages\desktop-app\package.json
json
{
"name": "currency-distributor-desktop",
"private": true,
"version": "0.1.0",
"main": "dist-electron/main.js",
"description": "Desktop application for Currency Denomination Distributor",
"author": "Currency Distributor Team",
"scripts": {
"dev": "vite",
"build": "tsc && vite build && electron-builder",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"axios": "^1.6.7",
"clsx": "^2.1.0",
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@types/node": "^20.11.24",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"electron": "^29.1.0",
"electron-builder": "^24.13.3",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.2",
"vite": "^5.1.5",
"vite-plugin-electron": "^0.28.2",
"vite-plugin-electron-renderer": "^0.14.5"
}
}
?? packages\desktop-app\postcss.config.js
javascript
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
?? packages\desktop-app\README.md
markdown
# Currency Denomination Distributor - Desktop App
This is the desktop application for the Currency Denomination Distributor system, built with Electron, React, TypeScript, and Tailwind CSS.
## Prerequisites
- Node.js (v18 or higher)
- npm (v9 or higher)
## Setup
1. Navigate to the desktop app directory:
```bash
cd packages/desktop-app
```
2. Install dependencies:
```bash
npm install
```
## Development
To start the application in development mode (with Hot Module Replacement):
```bash
npm run dev
```
This will launch the Electron window with the React application running inside.
## Building
To build the application for production:
```bash
npm run build
```
The output will be in the `dist` and `dist-electron` directories.
## Project Structure
- `electron/` - Electron main process code
- `src/` - React renderer process code
- `dist/` - Built assets
- `public/` - Static assets
## Integration
This desktop app connects to the local backend API running at `http://localhost:8001`. Ensure the backend is running before using the app.
?? packages\desktop-app\tailwind.config.js
javascript
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {},
},
plugins: [require("tailwindcss-animate")],
}
?? packages\desktop-app\TRANSLATIONS.md
markdown
# Multi-Language Support Documentation
## Overview
The Currency Denomination Distributor application now supports 5 languages:
- **English** (en) - Default
- **Hindi** (hi) - हिन्दी
- **Spanish** (es) - Espaol
- **French** (fr) - Franais
- **German** (de) - Deutsch
## Architecture
### Backend (FastAPI)
- **Translation Files**: Located in `packages/local-backend/app/locales/`
- `en.json` - English translations
- `hi.json` - Hindi translations
- `es.json` - Spanish translations
- `fr.json` - French translations
- `de.json` - German translations
- **API Endpoints**:
- `GET /api/v1/translations/languages` - Get list of supported languages
- `GET /api/v1/translations/{language_code}` - Get translations for specific language
- `GET /api/v1/translations` - Get all translations (admin/debug)
- **Settings Integration**:
- Language preference stored in `language` setting (default: "en")
- Persists across sessions via SQLite database
### Frontend (React + TypeScript)
- **Context**: `LanguageContext.tsx` provides:
- `language` - Current language code
- `translations` - All translation strings for current language
- `setLanguage(code)` - Change language and reload translations
- `t(key, params?)` - Translation function with parameter support
- `isLoading` - Loading state
- `supportedLanguages` - Available languages
- **Provider**: Wraps entire app in `main.tsx`
```tsx
<LanguageProvider>
<App />
</LanguageProvider>
```
## Usage
### In Components
```tsx
import { useLanguage } from '../contexts/LanguageContext';
function MyComponent() {
const { t, language, setLanguage } = useLanguage();
return (
<div>
<h1>{t('app.title')}</h1>
<p>{t('app.subtitle')}</p>
{/* With parameters */}
<p>{t('quickAccess.timeAgo.minutesAgo', { count: 5 })}</p>
{/* Change language */}
<button onClick={() => setLanguage('hi')}>
Switch to Hindi
</button>
</div>
);
}
```
### Translation Keys
Use dot notation to access nested translations:
```
t('nav.calculator') → "Calculator"
t('settings.title') → "Settings"
t('common.save') → "Save"
t('results.totalAmount') → "Total Amount"
```
### Parameters
Some translations support dynamic parameters using `{param}` syntax:
```json
{
"timeAgo": {
"minutesAgo": "{count} minutes ago"
}
}
```
Usage:
```tsx
t('quickAccess.timeAgo.minutesAgo', { count: 5 })
// Output: "5 minutes ago"
```
## Translation File Structure
Each language file follows this structure:
```json
{
"app": {
"title": "Currency Denomination Distributor",
"subtitle": "Smart Cash Distribution System"
},
"nav": {
"calculator": "Calculator",
"history": "History",
"settings": "Settings"
},
"calculator": { ... },
"results": { ... },
"history": { ... },
"quickAccess": { ... },
"settings": { ... },
"currencies": { ... },
"common": { ... }
}
```
## Adding New Translations
### 1. Add to Backend Translation Files
Update all 5 JSON files in `packages/local-backend/app/locales/`:
```json
{
"newSection": {
"newKey": "New Text"
}
}
```
### 2. Use in Frontend
```tsx
{t('newSection.newKey')}
```
## Adding New Languages
### Backend
1. Create new JSON file: `packages/local-backend/app/locales/{code}.json`
2. Copy structure from `en.json` and translate all strings
3. Update `SUPPORTED_LANGUAGES` in `packages/local-backend/app/api/translations.py`:
```python
SUPPORTED_LANGUAGES = {
"en": "English",
"hi": "हिन्दी (Hindi)",
"es": "Espaol (Spanish)",
"fr": "Franais (French)",
"de": "Deutsch (German)",
"it": "Italiano (Italian)" # New language
}
```
### Frontend
No changes needed! The language will automatically appear in the Settings dropdown.
## Fallback Behavior
1. **Missing Translation**: If a translation key doesn't exist, the key itself is returned and a console warning is logged.
2. **Missing Language File**: If a language file fails to load, the system falls back to English.
3. **Network Error**: On API failure, English translations are loaded as fallback.
## Current Implementation Status
? **Fully Translated**:
- Navigation (Calculator, History, Settings)
- Settings page labels and descriptions
?? **Partially Translated** (hardcoded strings remain):
- Calculator form
- Results display
- History page
- Quick Access component
To complete translation implementation, replace all hardcoded strings with `t()` calls using the provided translation keys.
## Performance Notes
- Translations are loaded once on app initialization
- Language changes trigger a single API call to fetch new translations
- All translations are cached in React context
- No translation lookups on every render (O(1) dictionary access)
## Testing
### Test Language Switching
1. Go to Settings
2. Change language dropdown
3. Verify UI updates immediately
4. Refresh page - language should persist
### Test Translation Coverage
1. Switch to each language
2. Navigate through all tabs
3. Check for missing translations (look for translation keys instead of text)
4. Check browser console for translation warnings
## Future Enhancements
- [ ] Complete translation of all components
- [ ] Add more languages (Italian, Portuguese, Japanese, etc.)
- [ ] Translation management UI for administrators
- [ ] Automatic language detection from browser settings
- [ ] Region-specific formats (dates, numbers, currency symbols)
- [ ] Right-to-left (RTL) language support (Arabic, Hebrew)
?? packages\desktop-app\tsconfig.json
json
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src", "electron"],
"references": [{ "path": "./tsconfig.node.json" }]
}
?? packages\desktop-app\tsconfig.node.json
json
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
?? packages\desktop-app\vite.config.ts
plaintext
import { defineConfig } from 'vite'
import path from 'node:path'
import electron from 'vite-plugin-electron/simple'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
electron({
main: {
// Shortcut of `build.lib.entry`.
entry: 'electron/main.ts',
},
preload: {
// Shortcut of `build.rollupOptions.input`.
// Preload scripts may contain Web assets, so use the `build.rollupOptions.input` instead `build.lib.entry`.
input: 'electron/preload.ts',
},
// Ployfill the Electron and Node.js API for Renderer process.
// If you want use Node.js in Renderer process, the odeIntegration` needs to be enabled in the Main process.
// See ?? https://github.com/electron-vite/vite-plugin-electron-renderer
renderer: {},
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})
local-backend/
app/
api/
?? packages\local-backend\app\api\__init__.py
python
"""
API package initialization.
"""
from .calculations import router as calculations_router
from .history import router as history_router
from .export import router as export_router
from .settings import router as settings_router
__all__ = [
'calculations_router',
'history_router',
'export_router',
'settings_router'
]
?? packages\local-backend\app\api\calculations.py
python
"""
Calculations API endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel, Field
from decimal import Decimal, InvalidOperation
from typing import List, Optional, Dict, Any
from datetime import datetime, timezone
import json
import sys
from pathlib import Path
import csv
import io
from collections import Counter
import logging
# Configure logging
logger = logging.getLogger(__name__)
# Import OCR processor
from app.services.ocr_processor import get_ocr_processor
# Add core-engine to path
core_engine_path = Path(__file__).parent.parent.parent.parent / "core-engine"
sys.path.insert(0, str(core_engine_path))
from engine import DenominationEngine
from models import CalculationRequest as CoreRequest, OptimizationMode
from optimizer import OptimizationEngine
from fx_service import FXService
from app.database import get_db, Calculation
router = APIRouter()
# Initialize engines
denomination_engine = DenominationEngine()
optimization_engine = OptimizationEngine(denomination_engine)
fx_service = FXService()
# Pydantic models
class CalculateRequest(BaseModel):
"""Request model for calculation."""
amount: str | float = Field(..., description="Amount to break down (supports large numbers as string)")
currency: str = Field(..., min_length=3, max_length=3, description="Currency code (e.g., INR, USD)")
optimization_mode: str = Field(default="greedy", description="Optimization mode")
source_currency: Optional[str] = Field(None, description="Source currency for FX conversion")
convert_before_breakdown: bool = Field(True, description="Convert before or after breakdown")
save_to_history: bool = Field(True, description="Save to history")
class Config:
json_schema_extra = {
"example": {
"amount": 50000,
"currency": "INR",
"optimization_mode": "greedy",
"save_to_history": True
}
}
class CalculateResponse(BaseModel):
"""Response model for calculation."""
id: Optional[int] = None
amount: str
currency: str
breakdowns: List[Dict[str, Any]]
total_notes: int
total_coins: int
total_denominations: int
optimization_mode: str
source_currency: Optional[str] = None
exchange_rate: Optional[str] = None
explanation: Optional[str] = None
created_at: Optional[datetime] = None
@router.post("/calculate", response_model=CalculateResponse)
async def calculate(
request: CalculateRequest,
db: Session = Depends(get_db)
):
"""
Calculate denomination breakdown for given amount.
This is the core endpoint used by the desktop app.
"""
try:
# Convert to Decimal for precision
amount_decimal = Decimal(str(request.amount))
# Handle FX conversion if needed
if request.source_currency and request.source_currency != request.currency:
converted_amount, rate, rate_timestamp = fx_service.convert_amount(
amount_decimal,
request.source_currency,
request.currency,
use_live=False # Offline mode
)
amount_to_use = converted_amount
exchange_rate = rate
else:
amount_to_use = amount_decimal
exchange_rate = None
# Create calculation request
core_request = CoreRequest(
amount=amount_to_use,
currency=request.currency,
optimization_mode=OptimizationMode(request.optimization_mode),
source_currency=request.source_currency
)
# Calculate
result = denomination_engine.calculate(core_request)
# Save to history if requested
calculation_id = None
if request.save_to_history:
db_calc = Calculation(
amount=str(result.original_amount),
currency=result.currency,
source_currency=request.source_currency,
exchange_rate=str(exchange_rate) if exchange_rate else None,
optimization_mode=request.optimization_mode,
result=json.dumps(result.to_dict()),
total_notes=str(result.total_notes),
total_coins=str(result.total_coins),
total_denominations=str(result.total_denominations),
source="desktop",
synced=False
)
db.add(db_calc)
db.commit()
db.refresh(db_calc)
calculation_id = db_calc.id
# Format response
return CalculateResponse(
id=calculation_id,
amount=str(result.original_amount),
currency=result.currency,
breakdowns=[
{
"denomination": str(b.denomination),
"count": b.count,
"total_value": str(b.total_value),
"is_note": b.is_note
}
for b in result.breakdowns
],
total_notes=result.total_notes,
total_coins=result.total_coins,
total_denominations=result.total_denominations,
optimization_mode=request.optimization_mode,
source_currency=request.source_currency,
exchange_rate=str(exchange_rate) if exchange_rate else None,
created_at=datetime.now(timezone.utc)
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Calculation failed: {str(e)}")
@router.get("/currencies")
async def get_currencies():
"""Get list of supported currencies."""
try:
currencies = denomination_engine.get_supported_currencies()
details = []
for code in currencies:
info = denomination_engine.get_currency_info(code)
details.append(info)
return {
"currencies": details,
"count": len(details)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/currencies/{currency_code}")
async def get_currency_info(currency_code: str):
"""Get detailed information about a specific currency."""
try:
info = denomination_engine.get_currency_info(currency_code.upper())
return info
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/alternatives")
async def get_alternatives(request: CalculateRequest):
"""
Generate alternative denomination distributions.
Provides 2-3 alternative ways to break down the same amount.
"""
try:
amount_decimal = Decimal(str(request.amount))
core_request = CoreRequest(
amount=amount_decimal,
currency=request.currency,
optimization_mode=OptimizationMode(request.optimization_mode)
)
alternatives = optimization_engine.suggest_alternatives(core_request, count=3)
return {
"original_amount": str(amount_decimal),
"currency": request.currency,
"alternatives": [
{
"breakdowns": [
{
"denomination": str(b.denomination),
"count": b.count,
"total_value": str(b.total_value),
"is_note": b.is_note
}
for b in alt.breakdowns
],
"total_denominations": alt.total_denominations,
"optimization_mode": alt.optimization_mode.value,
"explanation": alt.metadata.get('explanation', '')
}
for alt in alternatives
],
"count": len(alternatives)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/exchange-rates")
async def get_exchange_rates(base: str = "USD"):
"""Get current exchange rates."""
try:
rates = fx_service.get_all_rates(base.upper(), use_live=False)
cache_age = fx_service.get_cache_age()
return {
"base_currency": base.upper(),
"rates": {k: str(v) for k, v in rates.items()},
"cache_age_hours": cache_age.total_seconds() / 3600 if cache_age else None,
"is_stale": fx_service.is_cache_stale(),
"timestamp": datetime.now(timezone.utc)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Bulk CSV Upload Models
class BulkCalculationRow(BaseModel):
"""Single row result from bulk calculation."""
row_number: int
status: str # "success" or "error"
amount: Optional[str] = None
currency: Optional[str] = None
optimization_mode: Optional[str] = None
total_notes: Optional[int] = None
total_coins: Optional[int] = None
total_denominations: Optional[int] = None
breakdowns: Optional[List[Dict[str, Any]]] = None
error: Optional[str] = None
calculation_id: Optional[int] = None
class BulkUploadResponse(BaseModel):
"""Response model for bulk CSV upload."""
total_rows: int
successful: int
failed: int
results: List[BulkCalculationRow]
processing_time_seconds: float
saved_to_history: bool
@router.post("/bulk-upload", response_model=BulkUploadResponse)
async def bulk_upload_file(
file: UploadFile = File(..., description="Upload: CSV, PDF, Word (.docx), or Image (JPG/PNG/etc.)"),
save_to_history: bool = True,
db: Session = Depends(get_db)
):
"""
*** REBUILT FROM SCRATCH ***
Bulk upload file for batch calculations - NO CACHED DATA.
Supported Formats:
- CSV files (.csv) - Direct parsing
- PDF files (.pdf) - Text extraction + OCR for scanned PDFs
- Word documents (.docx) - Text extraction from paragraphs/tables
- Images (.jpg, .png, .tiff, .bmp) - Tesseract OCR
CSV Format:
- Required columns: amount, currency
- Optional: optimization_mode
- Headers case-insensitive
Other Formats (OCR):
- Automatic text extraction
- Parses amounts, currencies, modes
- Supports: CSV-like, pipe-separated, tabular, natural language
**CRITICAL**: Every upload performs FRESH calculations - no cached results.
"""
import time
start_time = time.time()
logger.info(f"========== BULK UPLOAD START ==========")
logger.info(f"File: {file.filename}")
# Get file extension
file_ext = Path(file.filename).suffix.lower()
# Supported extensions
csv_ext = {'.csv'}
pdf_ext = {'.pdf'}
word_ext = {'.docx', '.doc'}
image_ext = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.bmp', '.gif', '.webp'}
all_supported = csv_ext | pdf_ext | word_ext | image_ext
if file_ext not in all_supported:
raise HTTPException(
status_code=400,
detail=f"Unsupported file: {file_ext}. Supported: CSV, PDF, Word, Images"
)
try:
# Read file data
file_data = await file.read()
logger.info(f"File size: {len(file_data)} bytes, Type: {file_ext}")
# Route to appropriate processor
if file_ext in csv_ext:
logger.info("Processing as CSV (direct parsing)")
rows_data = parse_csv_file(file_data, file.filename)
else:
logger.info(f"Processing with OCR (type: {file_ext})")
ocr_processor = get_ocr_processor()
# Check dependencies
deps = ocr_processor.check_dependencies()
missing = []
if file_ext in pdf_ext:
if not deps['pymupdf']:
missing.append('PyMuPDF')
if not deps['pdf2image']:
missing.append('pdf2image (for scanned PDFs)')
if file_ext in word_ext and not deps['docx']:
missing.append('python-docx')
if file_ext in image_ext and not deps['tesseract']:
missing.append('Tesseract OCR')
if missing:
raise HTTPException(
status_code=503,
detail=f"OCR dependencies missing: {', '.join(missing)}. Run: install_ocr_simple.ps1"
)
# Process file with OCR
rows_data = ocr_processor.process_file(file_data, file.filename)
logger.info(f"Extracted {len(rows_data)} rows from {file_ext}")
if not rows_data:
raise HTTPException(
status_code=400,
detail="No data rows found in file"
)
logger.info(f"Total rows to process: {len(rows_data)}")
logger.debug(f"First row sample: {rows_data[0]}")
# Process each row - FRESH CALCULATIONS ONLY
results = []
successful_count = 0
failed_count = 0
for idx, row_data in enumerate(rows_data, start=1):
row_num = row_data.get('row_number', idx)
logger.debug(f"[ROW {row_num}] Input: {row_data}")
try:
# Extract and validate fields
amount_str = row_data.get('amount', '').strip()
currency_raw = row_data.get('currency', '').strip()
optimization_raw = row_data.get('optimization_mode', '').strip()
if not amount_str:
raise ValueError("Amount is required")
if not currency_raw:
raise ValueError("Currency is required")
# Validate and normalize currency
currency = currency_raw.upper()
if len(currency) != 3:
raise ValueError(f"Invalid currency code: {currency_raw} (must be 3 letters)")
# Validate and normalize optimization mode
valid_modes = ['greedy', 'balanced', 'minimize_large', 'minimize_small']
optimization_mode = optimization_raw.lower() if optimization_raw else 'greedy'
if optimization_mode not in valid_modes:
logger.warning(f"[ROW {row_num}] Invalid mode '{optimization_raw}', using 'greedy'")
optimization_mode = 'greedy'
# Parse amount (handle scientific notation)
try:
clean_amount = amount_str.replace(' ', '').replace(',', '')
if 'E' in clean_amount.upper() or 'e' in clean_amount:
# Scientific notation: convert via float
amount_float = float(clean_amount)
amount_decimal = Decimal(str(amount_float))
logger.debug(f"[ROW {row_num}] Scientific notation: {amount_str} → {amount_decimal}")
else:
amount_decimal = Decimal(clean_amount)
if amount_decimal <= 0:
raise ValueError("Amount must be positive")
except (ValueError, InvalidOperation):
raise ValueError(f"Invalid amount: {amount_str}")
# **PERFORM FRESH CALCULATION** - No cached data
core_request = CoreRequest(
amount=amount_decimal,
currency=currency,
optimization_mode=OptimizationMode(optimization_mode)
)
logger.debug(f"[ROW {row_num}] Calculating: {amount_decimal} {currency} ({optimization_mode})")
# Calculate denomination breakdown
calculation_result = denomination_engine.calculate(core_request)
logger.debug(f"[ROW {row_num}] ✓ SUCCESS: {calculation_result.total_denominations} denominations")
# Optionally save to history
calculation_id = None
if save_to_history:
db_calc = Calculation(
amount=str(calculation_result.original_amount),
currency=calculation_result.currency,
source_currency=None,
exchange_rate=None,
optimization_mode=optimization_mode,
result=json.dumps(calculation_result.to_dict()),
total_notes=str(calculation_result.total_notes),
total_coins=str(calculation_result.total_coins),
total_denominations=str(calculation_result.total_denominations),
source="bulk_upload",
synced=False
)
db.add(db_calc)
db.commit()
db.refresh(db_calc)
calculation_id = db_calc.id
# Build success response
results.append(BulkCalculationRow(
row_number=row_num,
status="success",
amount=str(calculation_result.original_amount),
currency=calculation_result.currency,
optimization_mode=optimization_mode,
total_notes=calculation_result.total_notes,
total_coins=calculation_result.total_coins,
total_denominations=calculation_result.total_denominations,
breakdowns=[
{
"denomination": str(b.denomination),
"count": b.count,
"total_value": str(b.total_value),
"is_note": b.is_note
}
for b in calculation_result.breakdowns
],
calculation_id=calculation_id
))
successful_count += 1
except ValueError as e:
logger.warning(f"[ROW {row_num}] ✗ Validation error: {str(e)}")
results.append(BulkCalculationRow(
row_number=row_num,
status="error",
amount=row_data.get('amount', ''),
currency=row_data.get('currency', ''),
optimization_mode=row_data.get('optimization_mode', ''),
error=str(e)
))
failed_count += 1
except Exception as e:
logger.error(f"[ROW {row_num}] ✗ Unexpected error: {str(e)}", exc_info=True)
results.append(BulkCalculationRow(
row_number=row_num,
status="error",
amount=row_data.get('amount', ''),
currency=row_data.get('currency', ''),
optimization_mode=row_data.get('optimization_mode', ''),
error=f"Processing error: {str(e)}"
))
failed_count += 1
processing_time = time.time() - start_time
logger.info(f"========== BULK UPLOAD COMPLETE ==========")
logger.info(f"Total: {len(results)}, Success: {successful_count}, Failed: {failed_count}, Time: {processing_time:.3f}s")
return BulkUploadResponse(
total_rows=len(results),
successful=successful_count,
failed=failed_count,
results=results,
processing_time_seconds=round(processing_time, 3),
saved_to_history=save_to_history
)
except HTTPException:
raise
except UnicodeDecodeError:
raise HTTPException(
status_code=400,
detail="File encoding error. Ensure CSV is UTF-8 encoded"
)
except Exception as e:
logger.error(f"BULK UPLOAD FAILED: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Bulk upload failed: {str(e)}"
)
def parse_csv_file(csv_data: bytes, filename: str) -> List[Dict[str, Any]]:
"""Parse CSV file into structured rows with case-insensitive headers."""
csv_text = csv_data.decode('utf-8')
csv_reader = csv.DictReader(io.StringIO(csv_text))
if not csv_reader.fieldnames:
raise ValueError("CSV has no headers")
# Case-insensitive header mapping
header_map = {header.lower(): header for header in csv_reader.fieldnames}
# Check required columns
required_cols = ['amount', 'currency']
missing = [col for col in required_cols if col not in header_map]
if missing:
raise ValueError(f"Missing columns: {', '.join(missing)}")
rows_data = []
for row_num, row in enumerate(csv_reader, start=2):
amount_col = header_map.get('amount', 'amount')
currency_col = header_map.get('currency', 'currency')
opt_col = header_map.get('optimization_mode', 'optimization_mode')
rows_data.append({
'row_number': row_num,
'amount': row.get(amount_col, '').strip(),
'currency': row.get(currency_col, '').strip(),
'optimization_mode': row.get(opt_col, '').strip()
})
return rows_data
# Smart Currency Recommendation Models
class CurrencyUsageStat(BaseModel):
"""Currency usage statistics."""
currency: str
count: int
last_used: str
percentage: float
class SmartCurrencyRecommendation(BaseModel):
"""Smart currency recommendation response."""
recommended_currency: str
confidence: str # 'high', 'medium', 'low'
reason: str
alternatives: List[str]
usage_stats: List[CurrencyUsageStat]
system_info: Dict[str, str]
@router.get("/smart-currency", response_model=SmartCurrencyRecommendation)
async def get_smart_currency_recommendation(
timezone: Optional[str] = Query(None, description="Client timezone (e.g., 'Asia/Kolkata')"),
locale: Optional[str] = Query(None, description="Client locale (e.g., 'en-US')"),
language: Optional[str] = Query('en', description="Current app language"),
db: Session = Depends(get_db)
):
"""
Get smart currency recommendation based on:
- System timezone and region
- Historical usage patterns
- Language preferences
This endpoint analyzes the user's calculation history to determine
the most appropriate default currency automatically.
"""
try:
# Timezone to currency mapping
timezone_currency_map = {
# North America
'America/New_York': 'USD', 'America/Chicago': 'USD', 'America/Denver': 'USD',
'America/Los_Angeles': 'USD', 'America/Phoenix': 'USD', 'America/Toronto': 'CAD',
'America/Vancouver': 'CAD',
# Europe
'Europe/London': 'GBP', 'Europe/Paris': 'EUR', 'Europe/Berlin': 'EUR',
'Europe/Rome': 'EUR', 'Europe/Madrid': 'EUR', 'Europe/Amsterdam': 'EUR',
'Europe/Brussels': 'EUR', 'Europe/Vienna': 'EUR', 'Europe/Zurich': 'EUR',
# Asia
'Asia/Kolkata': 'INR', 'Asia/Mumbai': 'INR', 'Asia/Delhi': 'INR',
'Asia/Tokyo': 'JPY', 'Asia/Seoul': 'JPY', 'Asia/Shanghai': 'CNY',
'Asia/Beijing': 'CNY', 'Asia/Hong_Kong': 'CNY', 'Asia/Singapore': 'USD',
# Oceania
'Australia/Sydney': 'AUD', 'Australia/Melbourne': 'AUD',
}
# Language to currency fallback
language_currency_map = {
'en': 'USD', 'hi': 'INR', 'es': 'EUR', 'fr': 'EUR', 'de': 'EUR',
'ja': 'JPY', 'zh': 'CNY'
}
# Fetch user's calculation history to analyze currency usage
history = db.query(Calculation).order_by(Calculation.created_at.desc()).limit(1000).all()
usage_stats = []
recommended_currency = None
confidence = 'low'
reason = ''
alternatives = []
if history:
# Count currency usage
currency_counts = Counter(calc.currency for calc in history)
total_calculations = len(history)
# Build usage stats
for currency, count in currency_counts.most_common():
last_used_calc = next(
(calc for calc in history if calc.currency == currency),
None
)
usage_stats.append(CurrencyUsageStat(
currency=currency,
count=count,
last_used=last_used_calc.created_at.isoformat() if last_used_calc else '',
percentage=round((count / total_calculations) * 100, 2)
))
# Priority 1: Historical usage (if user has significant history)
if usage_stats and usage_stats[0].count >= 3:
recommended_currency = usage_stats[0].currency
confidence = 'high' if usage_stats[0].percentage >= 60 else 'medium'
reason = f"Based on your usage history ({usage_stats[0].count} calculations, {usage_stats[0].percentage:.0f}%)"
alternatives = [stat.currency for stat in usage_stats[1:4]]
# Priority 2: Timezone-based detection
if not recommended_currency and timezone:
if timezone in timezone_currency_map:
recommended_currency = timezone_currency_map[timezone]
confidence = 'high'
reason = f"Based on your system timezone ({timezone})"
else:
# Try region-based matching
region = timezone.split('/')[0] if '/' in timezone else None
if region == 'America':
recommended_currency = 'USD'
confidence = 'medium'
reason = f"Based on your region ({region})"
elif region == 'Europe':
recommended_currency = 'EUR'
confidence = 'medium'
reason = f"Based on your region ({region})"
elif region == 'Asia':
if 'India' in timezone or 'Kolkata' in timezone:
recommended_currency = 'INR'
elif 'Tokyo' in timezone or 'Japan' in timezone:
recommended_currency = 'JPY'
elif 'China' in timezone or 'Shanghai' in timezone or 'Beijing' in timezone:
recommended_currency = 'CNY'
else:
recommended_currency = 'USD'
confidence = 'medium'
reason = f"Based on your timezone ({timezone})"
elif region in ['Australia', 'Pacific']:
recommended_currency = 'AUD'
confidence = 'medium'
reason = f"Based on your region ({region})"
# Priority 3: Language-based fallback
if not recommended_currency:
recommended_currency = language_currency_map.get(language, 'USD')
confidence = 'medium'
reason = f"Based on your app language ({language})"
# Set alternatives if not already set
if not alternatives:
if timezone:
region = timezone.split('/')[0] if '/' in timezone else None
if region == 'America':
alternatives = ['USD', 'CAD']
elif region == 'Europe':
alternatives = ['EUR', 'GBP']
elif region == 'Asia':
alternatives = ['INR', 'JPY', 'CNY', 'USD']
elif region in ['Australia', 'Pacific']:
alternatives = ['AUD', 'USD']
else:
alternatives = ['USD', 'EUR', 'GBP']
else:
alternatives = ['USD', 'EUR', 'GBP', 'INR']
# Remove recommended from alternatives
alternatives = [c for c in alternatives if c != recommended_currency][:3]
return SmartCurrencyRecommendation(
recommended_currency=recommended_currency,
confidence=confidence,
reason=reason,
alternatives=alternatives,
usage_stats=usage_stats,
system_info={
'timezone': timezone or 'not provided',
'locale': locale or 'not provided',
'language': language,
'timestamp': datetime.now(timezone).isoformat()
}
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to get smart currency recommendation: {str(e)}"
)
?? packages\local-backend\app\api\export.py
python
"""
Export API endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from typing import Optional, List
import csv
import json
from datetime import datetime
from pathlib import Path
from io import StringIO, BytesIO
from app.database import get_db, Calculation, ExportRecord
from app.config import settings
router = APIRouter()
@router.get("/export/csv")
async def export_history_csv(
currency: Optional[str] = Query(None, description="Filter by currency"),
limit: Optional[int] = Query(None, description="Limit number of records"),
db: Session = Depends(get_db)
):
"""
Export calculation history to CSV format.
"""
try:
# Build query
query = db.query(Calculation)
if currency:
query = query.filter(Calculation.currency == currency.upper())
if limit:
query = query.limit(limit)
calculations = query.order_by(Calculation.created_at.desc()).all()
# Create CSV
output = StringIO()
writer = csv.writer(output)
# Header
writer.writerow([
'ID', 'Date', 'Amount', 'Currency',
'Total Notes', 'Total Coins', 'Total Denominations',
'Optimization Mode', 'Source', 'Synced'
])
# Data rows
for calc in calculations:
writer.writerow([
calc.id,
calc.created_at.strftime('%Y-%m-%d %H:%M:%S'),
calc.amount,
calc.currency,
calc.total_notes,
calc.total_coins,
calc.total_denominations,
calc.optimization_mode,
calc.source,
'Yes' if calc.synced else 'No'
])
# Save to file
filename = f"history_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
filepath = settings.EXPORT_DIR / filename
with open(filepath, 'w', newline='', encoding='utf-8') as f:
f.write(output.getvalue())
# Record export
export_record = ExportRecord(
export_type='csv',
file_path=str(filepath),
item_count=len(calculations),
file_size_bytes=filepath.stat().st_size
)
db.add(export_record)
db.commit()
return FileResponse(
path=filepath,
filename=filename,
media_type='text/csv'
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
@router.get("/export/calculation/{calculation_id}/csv")
async def export_single_csv(
calculation_id: int,
db: Session = Depends(get_db)
):
"""Export a single calculation breakdown to CSV."""
try:
calc = db.query(Calculation).filter(Calculation.id == calculation_id).first()
if not calc:
raise HTTPException(status_code=404, detail="Calculation not found")
# Parse result
result_data = json.loads(calc.result)
breakdowns = result_data.get('breakdowns', [])
# Create CSV
output = StringIO()
writer = csv.writer(output)
# Header
writer.writerow(['Denomination', 'Count', 'Total Value', 'Type'])
# Data rows
for b in breakdowns:
writer.writerow([
b['denomination'],
b['count'],
b['total_value'],
'Note' if b['is_note'] else 'Coin'
])
# Summary
writer.writerow([])
writer.writerow(['Summary', '', '', ''])
writer.writerow(['Total Notes', calc.total_notes, '', ''])
writer.writerow(['Total Coins', calc.total_coins, '', ''])
writer.writerow(['Total Denominations', calc.total_denominations, '', ''])
# Save to file
filename = f"calculation_{calculation_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
filepath = settings.EXPORT_DIR / filename
with open(filepath, 'w', newline='', encoding='utf-8') as f:
f.write(output.getvalue())
return FileResponse(
path=filepath,
filename=filename,
media_type='text/csv'
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
@router.get("/export/formats")
async def get_export_formats():
"""Get available export formats."""
return {
"formats": [
{
"type": "csv",
"name": "CSV",
"description": "Comma-separated values",
"supported": True
},
{
"type": "excel",
"name": "Excel",
"description": "Microsoft Excel spreadsheet",
"supported": False,
"note": "Coming soon"
},
{
"type": "pdf",
"name": "PDF",
"description": "Portable Document Format",
"supported": False,
"note": "Coming soon"
}
]
}
?? packages\local-backend\app\api\history.py
python
"""
History API endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import desc
from pydantic import BaseModel
from typing import List, Optional
from datetime import datetime, timedelta
import json
import csv
import io
from app.database import get_db, Calculation
from app.config import settings
router = APIRouter()
class HistoryItem(BaseModel):
"""History item response model."""
id: int
amount: str
currency: str
total_notes: int
total_coins: int
total_denominations: int
optimization_mode: str
source: str
synced: bool
created_at: datetime
@classmethod
def from_db(cls, db_item):
"""Convert database item to response model, handling string to int conversion."""
# Ensure datetime is timezone-aware (treat as UTC if naive)
created_at = db_item.created_at
if created_at and created_at.tzinfo is None:
from datetime import timezone
created_at = created_at.replace(tzinfo=timezone.utc)
return cls(
id=db_item.id,
amount=db_item.amount,
currency=db_item.currency,
total_notes=int(db_item.total_notes) if db_item.total_notes else 0,
total_coins=int(db_item.total_coins) if db_item.total_coins else 0,
total_denominations=int(db_item.total_denominations) if db_item.total_denominations else 0,
optimization_mode=db_item.optimization_mode,
source=db_item.source,
synced=db_item.synced,
created_at=created_at
)
class Config:
from_attributes = True
json_encoders = {
datetime: lambda v: v.isoformat() if v else None
}
class HistoryResponse(BaseModel):
"""History list response."""
items: List[HistoryItem]
total: int
page: int
page_size: int
has_more: bool
@router.get("/history", response_model=HistoryResponse)
async def get_history(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(50, ge=1, le=1000, description="Items per page"),
currency: Optional[str] = Query(None, description="Filter by currency"),
synced: Optional[bool] = Query(None, description="Filter by sync status"),
db: Session = Depends(get_db)
):
"""
Get calculation history with pagination and filtering.
"""
try:
# Build query
query = db.query(Calculation)
# Apply filters
if currency:
query = query.filter(Calculation.currency == currency.upper())
if synced is not None:
query = query.filter(Calculation.synced == synced)
# Get total count
total = query.count()
# Apply pagination
offset = (page - 1) * page_size
items = query.order_by(desc(Calculation.created_at)).offset(offset).limit(page_size).all()
# Check if there are more items
has_more = total > (page * page_size)
return HistoryResponse(
items=[HistoryItem.from_db(item) for item in items],
total=total,
page=page,
page_size=page_size,
has_more=has_more
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history/quick-access")
async def get_quick_access(
count: int = Query(settings.QUICK_ACCESS_COUNT, ge=1, le=50),
db: Session = Depends(get_db)
):
"""
Get last N calculations for quick access sidebar.
This is optimized for the desktop app's quick access feature.
"""
try:
items = db.query(Calculation).order_by(
desc(Calculation.created_at)
).limit(count).all()
return {
"items": [HistoryItem.from_db(item) for item in items],
"count": len(items)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history/{calculation_id}")
async def get_calculation_detail(
calculation_id: int,
db: Session = Depends(get_db)
):
"""Get detailed information about a specific calculation."""
try:
from datetime import timezone
calc = db.query(Calculation).filter(Calculation.id == calculation_id).first()
if not calc:
raise HTTPException(status_code=404, detail="Calculation not found")
# Parse result JSON
result_data = json.loads(calc.result)
# Convert string totals back to integers
total_notes = int(calc.total_notes) if calc.total_notes else 0
total_coins = int(calc.total_coins) if calc.total_coins else 0
total_denominations = int(calc.total_denominations) if calc.total_denominations else 0
# Ensure datetime is timezone-aware (treat as UTC if naive)
created_at = calc.created_at
if created_at and created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
updated_at = calc.updated_at
if updated_at and updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=timezone.utc)
return {
"id": calc.id,
"amount": calc.amount,
"currency": calc.currency,
"source_currency": calc.source_currency,
"exchange_rate": calc.exchange_rate,
"optimization_mode": calc.optimization_mode,
"result": result_data,
"total_notes": total_notes,
"total_coins": total_coins,
"total_denominations": total_denominations,
"source": calc.source,
"synced": calc.synced,
"created_at": created_at.isoformat() if created_at else None,
"updated_at": updated_at.isoformat() if updated_at else None
}
except HTTPException:
raise
except json.JSONDecodeError as e:
raise HTTPException(status_code=500, detail=f"Failed to parse calculation result: {str(e)}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/history/{calculation_id}")
async def delete_calculation(
calculation_id: int,
db: Session = Depends(get_db)
):
"""Delete a calculation from history."""
try:
calc = db.query(Calculation).filter(Calculation.id == calculation_id).first()
if not calc:
raise HTTPException(status_code=404, detail="Calculation not found")
db.delete(calc)
db.commit()
return {"message": "Calculation deleted successfully", "id": calculation_id}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/history")
async def clear_history(
older_than_days: Optional[int] = Query(None, description="Delete items older than N days"),
currency: Optional[str] = Query(None, description="Delete only specific currency"),
db: Session = Depends(get_db)
):
"""
Clear calculation history with optional filters.
"""
try:
query = db.query(Calculation)
# Apply filters
if older_than_days:
cutoff_date = datetime.utcnow() - timedelta(days=older_than_days)
query = query.filter(Calculation.created_at < cutoff_date)
if currency:
query = query.filter(Calculation.currency == currency.upper())
deleted_count = query.delete()
db.commit()
return {
"message": "History cleared successfully",
"deleted_count": deleted_count
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history/stats")
async def get_history_stats(db: Session = Depends(get_db)):
"""Get statistics about calculation history."""
try:
total_calculations = db.query(Calculation).count()
# Count by currency
currencies = {}
currency_results = db.query(
Calculation.currency,
db.func.count(Calculation.id)
).group_by(Calculation.currency).all()
for currency, count in currency_results:
currencies[currency] = count
# Count synced vs unsynced
synced_count = db.query(Calculation).filter(Calculation.synced == True).count()
unsynced_count = db.query(Calculation).filter(Calculation.synced == False).count()
# Most recent
most_recent = db.query(Calculation).order_by(
desc(Calculation.created_at)
).first()
return {
"total_calculations": total_calculations,
"by_currency": currencies,
"synced": synced_count,
"unsynced": unsynced_count,
"most_recent": most_recent.created_at if most_recent else None
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
class BulkDeleteRequest(BaseModel):
"""Request model for bulk delete."""
ids: List[int]
@router.post("/history/bulk-delete")
async def bulk_delete_calculations(
request: BulkDeleteRequest,
db: Session = Depends(get_db)
):
"""Delete multiple calculations by IDs."""
try:
if not request.ids:
raise HTTPException(status_code=400, detail="No IDs provided")
deleted_count = db.query(Calculation).filter(
Calculation.id.in_(request.ids)
).delete(synchronize_session=False)
db.commit()
return {
"message": f"Deleted {deleted_count} calculations",
"deleted_count": deleted_count,
"requested_count": len(request.ids)
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
class ExportRequest(BaseModel):
"""Request model for exporting history."""
ids: Optional[List[int]] = None # If None, export all
currency: Optional[str] = None
start_date: Optional[datetime] = None
end_date: Optional[datetime] = None
@router.post("/history/export/csv")
async def export_history_csv(
request: ExportRequest,
db: Session = Depends(get_db)
):
"""Export calculation history to CSV."""
try:
# Build query
query = db.query(Calculation)
# Apply filters
if request.ids:
query = query.filter(Calculation.id.in_(request.ids))
if request.currency:
query = query.filter(Calculation.currency == request.currency.upper())
if request.start_date:
query = query.filter(Calculation.created_at >= request.start_date)
if request.end_date:
query = query.filter(Calculation.created_at <= request.end_date)
# Get calculations
calculations = query.order_by(desc(Calculation.created_at)).all()
if not calculations:
raise HTTPException(status_code=404, detail="No calculations found")
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
'ID', 'Date', 'Amount', 'Currency',
'Total Notes', 'Total Coins', 'Total Denominations',
'Optimization Mode', 'Source', 'Breakdown Details'
])
# Write data
for calc in calculations:
result_data = json.loads(calc.result)
breakdowns_summary = "; ".join([
f"{b['count']}x{b['denomination']}"
for b in result_data.get('breakdowns', [])
])
writer.writerow([
calc.id,
calc.created_at.strftime('%Y-%m-%d %H:%M:%S'),
calc.amount,
calc.currency,
calc.total_notes,
calc.total_coins,
calc.total_denominations,
calc.optimization_mode,
calc.source,
breakdowns_summary
])
# Prepare response
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=history_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.csv"
}
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
?? packages\local-backend\app\api\settings.py
python
"""
Settings API endpoints.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, Dict, Any
import json
from app.database import get_db, UserSetting
router = APIRouter()
class SettingUpdate(BaseModel):
"""Setting update request."""
key: str
value: Any
@router.get("/settings")
async def get_all_settings(db: Session = Depends(get_db)):
"""Get all user settings."""
try:
settings = db.query(UserSetting).all()
result = {}
for setting in settings:
try:
# Try to parse as JSON
result[setting.key] = json.loads(setting.value)
except json.JSONDecodeError:
# Store as string if not JSON
result[setting.key] = setting.value
return {
"settings": result,
"count": len(result)
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/settings/{key}")
async def get_setting(key: str, db: Session = Depends(get_db)):
"""Get a specific setting."""
try:
setting = db.query(UserSetting).filter(UserSetting.key == key).first()
if not setting:
return {"key": key, "value": None, "exists": False}
try:
value = json.loads(setting.value)
except json.JSONDecodeError:
value = setting.value
return {
"key": key,
"value": value,
"exists": True,
"updated_at": setting.updated_at
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/settings")
async def update_setting(
setting: SettingUpdate,
db: Session = Depends(get_db)
):
"""Update or create a setting."""
try:
# Convert value to JSON string for proper type preservation
value_str = json.dumps(setting.value)
# Check if exists
existing = db.query(UserSetting).filter(UserSetting.key == setting.key).first()
if existing:
existing.value = value_str
message = "Setting updated"
else:
new_setting = UserSetting(key=setting.key, value=value_str)
db.add(new_setting)
message = "Setting created"
db.commit()
return {
"message": message,
"key": setting.key,
"value": setting.value
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/settings/{key}")
async def delete_setting(key: str, db: Session = Depends(get_db)):
"""Delete a setting."""
try:
setting = db.query(UserSetting).filter(UserSetting.key == key).first()
if not setting:
raise HTTPException(status_code=404, detail="Setting not found")
db.delete(setting)
db.commit()
return {"message": "Setting deleted", "key": key}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
# Predefined setting keys with defaults
DEFAULT_SETTINGS = {
"theme": "light",
"default_currency": "INR",
"default_optimization_mode": "greedy",
"quick_access_count": 10,
"quick_access_enabled": True,
"auto_save_history": True,
"sync_enabled": True,
"language": "en"
}
@router.post("/settings/reset")
async def reset_to_defaults(db: Session = Depends(get_db)):
"""Reset all settings to defaults."""
try:
# Delete all existing settings
db.query(UserSetting).delete()
# Create default settings
for key, value in DEFAULT_SETTINGS.items():
value_str = json.dumps(value) if isinstance(value, (dict, list, bool)) else str(value)
setting = UserSetting(key=key, value=value_str)
db.add(setting)
db.commit()
return {
"message": "Settings reset to defaults",
"settings": DEFAULT_SETTINGS
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
?? packages\local-backend\app\api\translations.py
python
"""
Translations API endpoints.
"""
from fastapi import APIRouter, HTTPException
from typing import Dict, Any
import json
import os
router = APIRouter()
# Get the directory where locales are stored
LOCALES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "locales")
# Supported languages
SUPPORTED_LANGUAGES = {
"en": "English",
"hi": "हिन्दी (Hindi)",
"es": "Espaol (Spanish)",
"fr": "Franais (French)",
"de": "Deutsch (German)"
}
def load_translation(language_code: str) -> Dict[str, Any]:
"""Load translation file for a specific language."""
file_path = os.path.join(LOCALES_DIR, f"{language_code}.json")
if not os.path.exists(file_path):
# Fallback to English if language file doesn't exist
file_path = os.path.join(LOCALES_DIR, "en.json")
try:
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to load translation file: {str(e)}"
)
@router.get("/translations/languages")
async def get_supported_languages():
"""Get list of supported languages."""
return {
"languages": [
{"code": code, "name": name}
for code, name in SUPPORTED_LANGUAGES.items()
],
"default": "en"
}
@router.get("/translations/{language_code}")
async def get_translations(language_code: str):
"""Get translations for a specific language."""
if language_code not in SUPPORTED_LANGUAGES:
raise HTTPException(
status_code=400,
detail=f"Unsupported language: {language_code}. Supported: {list(SUPPORTED_LANGUAGES.keys())}"
)
try:
translations = load_translation(language_code)
return {
"language": language_code,
"language_name": SUPPORTED_LANGUAGES[language_code],
"translations": translations
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/translations")
async def get_all_translations():
"""Get all available translations (for debugging/admin purposes)."""
try:
all_translations = {}
for lang_code in SUPPORTED_LANGUAGES.keys():
all_translations[lang_code] = load_translation(lang_code)
return {
"languages": SUPPORTED_LANGUAGES,
"translations": all_translations
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
locales/
?? packages\local-backend\app\locales\de.json
json
{
"app": {
"title": "Whrungsstckelungs-Verteiler",
"subtitle": "Intelligentes Bargeldverteilungssystem"
},
"nav": {
"calculator": "Rechner",
"history": "Verlauf",
"bulkUpload": "Massen-Upload",
"recent": "Letzte Berechnungen",
"settings": "Einstellungen"
},
"calculator": {
"title": "Whrungsrechner",
"subtitle": "Berechnen Sie die Stckelungsaufschlsselung sofort",
"amount": "Betrag",
"amountPlaceholder": "0.00",
"currency": "Whrung",
"selectCurrency": "Whrung auswhlen",
"calculate": "Aufschlsselung Berechnen",
"calculating": "Berechnung luft...",
"clear": "Lschen",
"reset": "Zurcksetzen",
"advancedOptions": "Erweiterte Optionen",
"optimizationMode": "Optimierungsmodus",
"greedy": "Gierig (Gesamtstckelungen Minimieren)",
"balanced": "Ausgewogen",
"minimizeLarge": "Groe Scheine Minimieren",
"minimizeSmall": "Kleine Scheine Minimieren",
"networkError": "Kann keine Verbindung zum Backend-Server herstellen. Luft er?",
"enterAmount": "Bitte geben Sie einen Betrag ein",
"validAmount": "Bitte geben Sie einen gltigen positiven Betrag ein",
"largeAmountWarning": "Dies ist ein extrem hoher Betrag. Die Berechnung kann eine Weile dauern. Fortfahren?",
"smartCurrency": "Intelligente Whrung",
"errors": {
"required": "Betrag ist erforderlich",
"positive": "Betrag muss positiv sein",
"invalid": "Ungltiger Betrag"
}
},
"results": {
"title": "Aufschlsselungsergebnisse",
"totalAmount": "Gesamtbetrag",
"totalNotes": "Gesamtanzahl Scheine",
"totalCoins": "Gesamtanzahl Mnzen",
"totalDenominations": "Gesamte Stckelungen",
"total": "Gesamt",
"breakdown": "Aufschlsselung",
"notes": "Scheine",
"coins": "Mnzen",
"denomination": "Stckelung",
"type": "Typ",
"note": "Schein",
"coin": "Mnze",
"count": "Anzahl",
"value": "Wert",
"totalValue": "Gesamtwert",
"export": "Ergebnisse Exportieren",
"exportCSV": "Als CSV exportieren",
"exportPDF": "Als PDF exportieren",
"exportWord": "Als Word exportieren",
"print": "Drucken",
"spreadsheetFormat": "Tabellenkalkulationsformat",
"portableDocument": "Portables Dokument",
"wordFormat": "Microsoft Word-Format",
"printPreview": "Druckvorschau und drucken",
"noResults": "Keine Ergebnisse zum Anzeigen",
"calculate": "Berechnen Sie einen Betrag, um Ergebnisse zu sehen",
"exportFailed": "Export fehlgeschlagen. Bitte versuchen Sie es erneut.",
"allowPopups": "Bitte erlauben Sie Pop-ups zum Exportieren",
"copy": "Kopieren",
"copyResults": "Ergebnisse Kopieren",
"copyAsText": "Als Text Kopieren",
"copyAsJSON": "Als JSON Kopieren",
"copiedToClipboard": "In die Zwischenablage kopiert!",
"copyFailed": "Kopieren fehlgeschlagen. Bitte versuchen Sie es erneut.",
"textFormat": "Klartext-Format",
"jsonFormat": "JSON-Format fr Entwickler"
},
"history": {
"title": "Berechnungsverlauf",
"totalCalculations": "Berechnungen insgesamt",
"generated": "Erstellt",
"filteredBy": "Gefiltert nach",
"reportTitle": "Berechnungsverlauf-Bericht",
"pageNumber": "Seite",
"morePagesAvailable": "(weitere Seiten verfgbar)",
"optimizationMode": "Optimierung",
"mode": "Modus",
"dateTime": "Datum & Uhrzeit",
"totalDenominations": "Stckelungen Gesamt",
"noHistory": "Keine Berechnungen gefunden",
"startCalculating": "Beginnen Sie mit der Erstellung einer neuen Berechnung",
"date": "Datum",
"amount": "Betrag",
"currency": "Whrung",
"notes": "Scheine",
"coins": "Mnzen",
"total": "Gesamt",
"timestamp": "Zeitstempel",
"actions": "Aktionen",
"view": "Ansehen",
"viewDetails": "Details Anzeigen",
"delete": "Lschen",
"deleteAll": "Alle Lschen",
"exportAll": "Alle Exportieren",
"exportSelected": "Auswahl Exportieren",
"deleteSelected": "Auswahl Lschen",
"selected": "ausgewhlt",
"allCurrencies": "Alle Whrungen",
"confirmDelete": "Sind Sie sicher, dass Sie diese Berechnung lschen mchten?",
"confirmDeleteSelected": "{count} ausgewhlte Berechnungen lschen?",
"confirmDeleteAll": "Sind Sie sicher, dass Sie ALLE {count} {currency}-Berechnungen lschen mchten? Diese Aktion kann nicht rckgngig gemacht werden!",
"confirmDeleteAllGlobal": "Sind Sie sicher, dass Sie ALLE {count} Berechnungen lschen mchten? Diese Aktion kann nicht rckgngig gemacht werden!",
"confirmDeletePermanent": "Dadurch werden alle Verlaufsdaten dauerhaft gelscht. Sind Sie absolut sicher?",
"deleteSuccess": "{count} Berechnung(en) erfolgreich gelscht",
"deleteFailed": "Lschen fehlgeschlagen",
"exportFailed": "Export fehlgeschlagen",
"noHistoryToDelete": "Kein Verlauf zum Lschen vorhanden",
"noHistoryToExport": "Keine Verlaufsdaten zum Exportieren vorhanden",
"loadFailed": "Laden des Verlaufs fehlgeschlagen",
"detailsFailed": "Laden der Details fehlgeschlagen",
"showing": "Anzeige von",
"of": "von",
"previous": "Zurck",
"next": "Weiter",
"copyAll": "Alles Kopieren",
"copySelected": "Auswahl Kopieren",
"copiedItems": "{count} Element(e) in die Zwischenablage kopiert!",
"nothingToCopy": "Keine Elemente zum Kopieren"
},
"quickAccess": {
"title": "Letzte Berechnungen",
"recent": "Krzlich",
"refresh": "Aktualisieren",
"noItems": "Keine letzten Berechnungen",
"startUsing": "Beginnen Sie mit dem Rechner, um krzliche Eintrge hier zu sehen",
"notesCount": "{count} Scheine",
"coinsCount": "{count} Mnzen",
"timeAgo": {
"justNow": "Gerade eben",
"minuteAgo": "Vor 1 Minute",
"minutesAgo": "Vor {count} Minuten",
"hourAgo": "Vor 1 Stunde",
"hoursAgo": "Vor {count} Stunden",
"dayAgo": "Vor 1 Tag",
"daysAgo": "Vor {count} Tagen",
"weekAgo": "Vor 1 Woche",
"weeksAgo": "Vor {count} Wochen",
"monthAgo": "Vor 1 Monat",
"monthsAgo": "Vor {count} Monaten",
"yearAgo": "Vor 1 Jahr",
"yearsAgo": "Vor {count} Jahren"
}
},
"settings": {
"title": "Einstellungen",
"subtitle": "Passen Sie Ihre Anwendungseinstellungen an",
"general": "Allgemeine Einstellungen",
"appearance": "Erscheinungsbild",
"appearanceDesc": "Zwischen hellem und dunklem Modus wechseln",
"preferences": "Prferenzen",
"data": "Datenverwaltung",
"about": "ber",
"theme": "Design",
"light": "Hell",
"dark": "Dunkel",
"system": "System",
"language": "Sprache",
"selectLanguage": "Sprache auswhlen",
"languageDesc": "Whlen Sie Ihre bevorzugte Sprache fr die Anwendungsoberflche",
"languageRegion": "Sprache & Region",
"defaultPreferences": "Standardeinstellungen",
"behavior": "Verhalten",
"dataSync": "Daten & Synchronisation",
"defaultCurrency": "Standardwhrung",
"defaultOptimization": "Standard-Optimierungsmodus",
"quickAccess": "Schnellzugriff",
"quickAccessEnabled": "Schnellzugriff Aktivieren",
"quickAccessCount": "Schnellzugriff-Anzahl",
"quickAccessCountDesc": "Anzahl der anzuzeigenden letzten Berechnungen (5-20)",
"quickAccessDesc": "Zeige letzte Berechnungen in der Seitenleiste",
"autoSaveHistory": "Automatisch im Verlauf Speichern",
"autoSaveDesc": "Berechnungen automatisch im Verlauf speichern",
"syncEnabled": "Cloud-Synchronisation",
"syncDesc": "Daten zwischen Gerten synchronisieren (demnchst)",
"clearHistory": "Verlauf Lschen",
"clearHistoryDesc": "Gesamten Berechnungsverlauf lschen",
"resetSettings": "Auf Standard Zurcksetzen",
"resetSettingsDesc": "Alle Einstellungen auf Standardwerte zurcksetzen",
"resetToDefaults": "Auf Standard Zurcksetzen",
"saveChanges": "nderungen Speichern",
"saving": "Speichern...",
"version": "Version",
"save": "Speichern",
"cancel": "Abbrechen",
"saved": "Einstellungen erfolgreich gespeichert!",
"error": "Fehler beim Speichern der Einstellungen",
"loadError": "Fehler beim Laden der Einstellungen",
"quickAccessEnabled_success": "Schnellzugriff aktiviert",
"quickAccessDisabled_success": "Schnellzugriff deaktiviert",
"currencyUpdated": "Standardwhrung aktualisiert zu {currency}",
"languageUpdated": "Sprache erfolgreich aktualisiert",
"optimizationUpdated": "Standard-Optimierungsmodus aktualisiert",
"autoSaveEnabled": "Automatisches Speichern im Verlauf aktiviert",
"autoSaveDisabled": "Automatisches Speichern im Verlauf deaktiviert",
"quickAccessCountError": "Schnellzugriff-Anzahl muss zwischen 5 und 20 liegen",
"quickAccessCountUpdated": "Schnellzugriff-Anzahl aktualisiert auf {count}",
"resetConfirm": "Sind Sie sicher, dass Sie alle Einstellungen auf die Standardwerte zurcksetzen mchten?",
"resetSuccess": "Einstellungen erfolgreich auf Standardwerte zurckgesetzt!",
"resetError": "Fehler beim Zurcksetzen der Einstellungen",
"dataStorageTitle": "Datenspeicherung",
"dataStorageDesc": "Alle Ihre Einstellungen und Berechnungsverlufe werden lokal auf Ihrem Gert gespeichert. Cloud-Synchronisation wird in einem zuknftigen Update verfgbar sein."
},
"bulkUpload": {
"title": "Massen-CSV-Upload",
"subtitle": "Laden Sie eine CSV-Datei hoch, um mehrere Berechnungen gleichzeitig zu verarbeiten",
"downloadTemplate": "Vorlage herunterladen",
"dragDropTitle": "Ziehen Sie Ihre CSV-Datei hierher",
"dragDropSubtitle": "oder klicken Sie zum Durchsuchen und Auswhlen einer Datei",
"selectFile": "Datei auswhlen",
"removeFile": "Datei entfernen",
"upload": "Hochladen und verarbeiten",
"uploading": "Datei wird hochgeladen...",
"processing": "Berechnungen werden verarbeitet...",
"pleaseWait": "Dies kann einen Moment dauern. Bitte warten...",
"uploadAnother": "Weitere Datei hochladen",
"saveToHistory": "Ergebnisse im Verlauf speichern",
"requirements": {
"title": "Dateianforderungen:",
"format": "Format: CSV (Comma-Separated Values)",
"columns": "Erforderlich: amount, currency | Optional: optimization_mode",
"size": "Maximale Dateigre: 10 MB",
"encoding": "Kodierung: UTF-8 empfohlen",
"caseInsensitive": "Gro-/Kleinschreibung unabhngig: Spaltenberschriften und Werte knnen in jedem Fall sein (Amount, AMOUNT, amount funktionieren)"
},
"errors": {
"invalidFileType": "Ungltiger Dateityp. Bitte laden Sie eine CSV-Datei hoch.",
"fileTooLarge": "Die Datei ist zu gro. Die maximale Gre betrgt 10 MB.",
"fileEmpty": "Die Datei ist leer. Bitte whlen Sie eine gltige CSV-Datei.",
"uploadFailed": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"error": "Upload-Fehler",
"results": {
"totalRows": "Gesamtzeilen",
"successful": "Erfolgreich",
"failed": "Fehlgeschlagen",
"processingTime": "Verarbeitungszeit",
"rowNumber": "Zeile #",
"status": "Status",
"amount": "Betrag",
"currency": "Whrung",
"denominations": "Stckelungen",
"details": "Details",
"success": "Erfolg",
"error": "Fehler",
"totalDenom": "Gesamt"
},
"exportCSV": "CSV exportieren",
"exportJSON": "JSON exportieren",
"copyResults": "Ergebnisse kopieren",
"copied": "Kopiert!"
},
"currencies": {
"INR": "Indische Rupie (?)",
"USD": "US-Dollar ($)",
"EUR": "Euro ()",
"GBP": "Britisches Pfund ()",
"JPY": "Japanischer Yen ()",
"CNY": "Chinesischer Yuan ()",
"AUD": "Australischer Dollar (A$)",
"CAD": "Kanadischer Dollar (C$)"
},
"common": {
"yes": "Ja",
"no": "Nein",
"ok": "OK",
"cancel": "Abbrechen",
"close": "Schlieen",
"save": "Speichern",
"delete": "Lschen",
"edit": "Bearbeiten",
"view": "Ansehen",
"search": "Suchen",
"filter": "Filtern",
"clear": "Lschen",
"loading": "Ldt...",
"error": "Fehler",
"success": "Erfolg",
"warning": "Warnung",
"info": "Information",
"confirm": "Besttigen"
}
}
?? packages\local-backend\app\locales\en.json
json
{
"app": {
"title": "Currency Denomination Distributor",
"subtitle": "Smart Cash Distribution System"
},
"nav": {
"calculator": "Calculator",
"history": "History",
"bulkUpload": "Bulk Upload",
"recent": "Recent Calculations",
"settings": "Settings"
},
"calculator": {
"title": "Currency Calculator",
"subtitle": "Calculate denomination breakdown instantly",
"amount": "Amount",
"amountPlaceholder": "0.00",
"currency": "Currency",
"selectCurrency": "Select currency",
"calculate": "Calculate Breakdown",
"calculating": "Calculating...",
"clear": "Clear",
"reset": "Reset",
"advancedOptions": "Advanced Options",
"optimizationMode": "Optimization Mode",
"greedy": "Greedy (Minimize Total Denominations)",
"balanced": "Balanced",
"minimizeLarge": "Minimize Large Notes",
"minimizeSmall": "Minimize Small Notes",
"networkError": "Cannot connect to the backend server. Is it running?",
"enterAmount": "Please enter an amount",
"validAmount": "Please enter a valid positive amount",
"largeAmountWarning": "This is an extremely large amount. The calculation may take a while. Continue?",
"smartCurrency": "Smart Currency",
"errors": {
"required": "Amount is required",
"positive": "Amount must be positive",
"invalid": "Invalid amount"
}
},
"results": {
"title": "Breakdown Results",
"totalAmount": "Total Amount",
"totalNotes": "Total Notes",
"totalCoins": "Total Coins",
"totalDenominations": "Total Denominations",
"total": "Total",
"breakdown": "Breakdown",
"notes": "Notes",
"coins": "Coins",
"denomination": "Denomination",
"type": "Type",
"note": "Note",
"coin": "Coin",
"count": "Count",
"value": "Value",
"totalValue": "Total Value",
"export": "Export Results",
"exportCSV": "Export as CSV",
"exportPDF": "Export as PDF",
"exportWord": "Export as Word",
"print": "Print",
"spreadsheetFormat": "Spreadsheet format",
"portableDocument": "Portable document",
"wordFormat": "Microsoft Word format",
"printPreview": "Print preview & print",
"noResults": "No results to display",
"calculate": "Calculate an amount to see results",
"exportFailed": "Failed to export. Please try again.",
"allowPopups": "Please allow popups to export",
"copy": "Copy",
"copyResults": "Copy Results",
"copyAsText": "Copy as Text",
"copyAsJSON": "Copy as JSON",
"copiedToClipboard": "Copied to clipboard!",
"copyFailed": "Failed to copy. Please try again.",
"textFormat": "Plain text format",
"jsonFormat": "JSON format for developers"
},
"history": {
"title": "Calculation History",
"totalCalculations": "total calculations",
"generated": "Generated",
"filteredBy": "Filtered by",
"reportTitle": "Calculation History Report",
"pageNumber": "Page",
"morePagesAvailable": "(more pages available)",
"optimizationMode": "Optimization",
"mode": "Mode",
"dateTime": "Date & Time",
"totalDenominations": "Total Denominations",
"noHistory": "No calculations found",
"startCalculating": "Start by creating a new calculation",
"date": "Date",
"amount": "Amount",
"currency": "Currency",
"notes": "Notes",
"coins": "Coins",
"total": "Total",
"timestamp": "Timestamp",
"actions": "Actions",
"view": "View",
"viewDetails": "View Details",
"delete": "Delete",
"deleteAll": "Delete All",
"exportAll": "Export All",
"exportSelected": "Export Selected",
"deleteSelected": "Delete Selected",
"selected": "selected",
"allCurrencies": "All Currencies",
"confirmDelete": "Are you sure you want to delete this calculation?",
"confirmDeleteSelected": "Delete {count} selected calculations?",
"confirmDeleteAll": "Are you sure you want to delete ALL {count} {currency} calculations? This action cannot be undone!",
"confirmDeleteAllGlobal": "Are you sure you want to delete ALL {count} calculations? This action cannot be undone!",
"confirmDeletePermanent": "This will permanently delete all history data. Are you absolutely sure?",
"deleteSuccess": "Successfully deleted {count} calculation(s)",
"deleteFailed": "Failed to delete",
"exportFailed": "Failed to export",
"noHistoryToDelete": "No history to delete",
"noHistoryToExport": "No history data to export",
"loadFailed": "Failed to load history",
"detailsFailed": "Failed to load details",
"showing": "Showing",
"of": "of",
"previous": "Previous",
"next": "Next",
"copyAll": "Copy All",
"copySelected": "Copy Selected",
"copiedItems": "Copied {count} item(s) to clipboard!",
"nothingToCopy": "No items to copy"
},
"quickAccess": {
"title": "Recent Calculations",
"recent": "Recent",
"refresh": "Refresh",
"noItems": "No recent calculations",
"startUsing": "Start using the calculator to see recent items here",
"notesCount": "{count} notes",
"coinsCount": "{count} coins",
"timeAgo": {
"justNow": "Just now",
"minuteAgo": "1 minute ago",
"minutesAgo": "{count} minutes ago",
"hourAgo": "1 hour ago",
"hoursAgo": "{count} hours ago",
"dayAgo": "1 day ago",
"daysAgo": "{count} days ago",
"weekAgo": "1 week ago",
"weeksAgo": "{count} weeks ago",
"monthAgo": "1 month ago",
"monthsAgo": "{count} months ago",
"yearAgo": "1 year ago",
"yearsAgo": "{count} years ago"
}
},
"settings": {
"title": "Settings",
"subtitle": "Customize your application preferences",
"general": "General Settings",
"appearance": "Appearance",
"appearanceDesc": "Switch between light and dark mode",
"preferences": "Preferences",
"data": "Data Management",
"about": "About",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System",
"language": "Language",
"selectLanguage": "Select language",
"languageDesc": "Select your preferred language for the application interface",
"languageRegion": "Language & Region",
"defaultPreferences": "Default Preferences",
"behavior": "Behavior",
"dataSync": "Data & Sync",
"defaultCurrency": "Default Currency",
"defaultOptimization": "Default Optimization Mode",
"quickAccess": "Quick Access",
"quickAccessEnabled": "Enable Quick Access",
"quickAccessCount": "Quick Access Count",
"quickAccessCountDesc": "Number of recent calculations to show (5-20)",
"quickAccessDesc": "Show recent calculations in sidebar",
"autoSaveHistory": "Auto-save to History",
"autoSaveDesc": "Automatically save calculations to history",
"syncEnabled": "Cloud Sync",
"syncDesc": "Sync data across devices (coming soon)",
"clearHistory": "Clear History",
"clearHistoryDesc": "Delete all calculation history",
"resetSettings": "Reset to Defaults",
"resetSettingsDesc": "Reset all settings to default values",
"resetToDefaults": "Reset to Defaults",
"saveChanges": "Save Changes",
"saving": "Saving...",
"version": "Version",
"save": "Save",
"cancel": "Cancel",
"saved": "Settings saved successfully!",
"error": "Failed to save settings",
"loadError": "Failed to load settings",
"quickAccessEnabled_success": "Quick Access enabled",
"quickAccessDisabled_success": "Quick Access disabled",
"currencyUpdated": "Default currency updated to {currency}",
"languageUpdated": "Language updated successfully",
"optimizationUpdated": "Default optimization mode updated",
"autoSaveEnabled": "Auto-save to history enabled",
"autoSaveDisabled": "Auto-save to history disabled",
"quickAccessCountError": "Quick Access Count must be between 5 and 20",
"quickAccessCountUpdated": "Quick Access count updated to {count}",
"resetConfirm": "Are you sure you want to reset all settings to defaults?",
"resetSuccess": "Settings reset to defaults successfully!",
"resetError": "Failed to reset settings",
"dataStorageTitle": "Data Storage",
"dataStorageDesc": "All your settings and calculation history are stored locally on your device. Cloud sync will be available in a future update."
},
"bulkUpload": {
"title": "Bulk CSV Upload",
"subtitle": "Upload a CSV file to process multiple calculations at once",
"downloadTemplate": "Download Template",
"dragDropTitle": "Drag and drop your CSV file here",
"dragDropSubtitle": "or click to browse and select a file",
"selectFile": "Select File",
"removeFile": "Remove File",
"upload": "Upload & Process",
"uploading": "Uploading File...",
"processing": "Processing Calculations...",
"pleaseWait": "This may take a moment. Please wait...",
"uploadAnother": "Upload Another File",
"saveToHistory": "Save results to history",
"requirements": {
"title": "File Requirements:",
"format": "Format: CSV (Comma-Separated Values)",
"columns": "Required: amount, currency | Optional: optimization_mode",
"size": "Maximum file size: 10 MB",
"encoding": "Encoding: UTF-8 recommended",
"caseInsensitive": "Case-insensitive: Column headers and values can be in any case (Amount, AMOUNT, amount all work)"
},
"errors": {
"invalidFileType": "Invalid file type. Please upload a CSV file.",
"fileTooLarge": "File is too large. Maximum size is 10 MB.",
"fileEmpty": "File is empty. Please select a valid CSV file.",
"uploadFailed": "Upload failed. Please try again."
},
"error": "Upload Error",
"results": {
"totalRows": "Total Rows",
"successful": "Successful",
"failed": "Failed",
"processingTime": "Processing Time",
"rowNumber": "Row #",
"status": "Status",
"amount": "Amount",
"currency": "Currency",
"denominations": "Denominations",
"details": "Details",
"success": "Success",
"error": "Error",
"totalDenom": "Total"
},
"exportCSV": "Export CSV",
"exportJSON": "Export JSON",
"copyResults": "Copy Results",
"copied": "Copied!"
},
"currencies": {
"INR": "Indian Rupee (?)",
"USD": "US Dollar ($)",
"EUR": "Euro ()",
"GBP": "British Pound ()",
"JPY": "Japanese Yen ()",
"CNY": "Chinese Yuan ()",
"AUD": "Australian Dollar (A$)",
"CAD": "Canadian Dollar (C$)"
},
"common": {
"yes": "Yes",
"no": "No",
"ok": "OK",
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"view": "View",
"search": "Search",
"filter": "Filter",
"clear": "Clear",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"warning": "Warning",
"info": "Information",
"confirm": "Confirm"
}
}
?? packages\local-backend\app\locales\es.json
json
{
"app": {
"title": "Distribuidor de Denominaciones de Moneda",
"subtitle": "Sistema Inteligente de Distribucin de Efectivo"
},
"nav": {
"calculator": "Calculadora",
"history": "Historial",
"bulkUpload": "Carga Masiva",
"recent": "Clculos Recientes",
"settings": "Configuracin"
},
"calculator": {
"title": "Calculadora de Moneda",
"subtitle": "Calcule el desglose de denominaciones al instante",
"amount": "Cantidad",
"amountPlaceholder": "0.00",
"currency": "Moneda",
"selectCurrency": "Seleccionar moneda",
"calculate": "Calcular Desglose",
"calculating": "Calculando...",
"clear": "Limpiar",
"reset": "Restablecer",
"advancedOptions": "Opciones Avanzadas",
"optimizationMode": "Modo de Optimizacin",
"greedy": "Codicioso (Minimizar Denominaciones Totales)",
"balanced": "Equilibrado",
"minimizeLarge": "Minimizar Billetes Grandes",
"minimizeSmall": "Minimizar Billetes Pequeos",
"networkError": "No se puede conectar al servidor backend. Est en ejecucin?",
"enterAmount": "Por favor ingrese una cantidad",
"validAmount": "Por favor ingrese una cantidad positiva vlida",
"largeAmountWarning": "Esta es una cantidad extremadamente grande. El clculo puede tardar un tiempo. Continuar?",
"smartCurrency": "Moneda Inteligente",
"errors": {
"required": "La cantidad es obligatoria",
"positive": "La cantidad debe ser positiva",
"invalid": "Cantidad invlida"
}
},
"results": {
"title": "Resultados del Desglose",
"totalAmount": "Cantidad Total",
"totalNotes": "Billetes Totales",
"totalCoins": "Monedas Totales",
"totalDenominations": "Denominaciones Totales",
"total": "Total",
"breakdown": "Desglose",
"notes": "Billetes",
"coins": "Monedas",
"denomination": "Denominacin",
"type": "Tipo",
"note": "Billete",
"coin": "Moneda",
"count": "Cantidad",
"value": "Valor",
"totalValue": "Valor Total",
"export": "Exportar Resultados",
"exportCSV": "Exportar como CSV",
"exportPDF": "Exportar como PDF",
"exportWord": "Exportar como Word",
"print": "Imprimir",
"spreadsheetFormat": "Formato de hoja de clculo",
"portableDocument": "Documento portable",
"wordFormat": "Formato de Microsoft Word",
"printPreview": "Vista previa e impresin",
"noResults": "No hay resultados para mostrar",
"calculate": "Calcule una cantidad para ver los resultados",
"exportFailed": "Error al exportar. Por favor, intntelo de nuevo.",
"allowPopups": "Por favor, permita las ventanas emergentes para exportar",
"copy": "Copiar",
"copyResults": "Copiar Resultados",
"copyAsText": "Copiar como Texto",
"copyAsJSON": "Copiar como JSON",
"copiedToClipboard": "Copiado al portapapeles!",
"copyFailed": "Error al copiar. Por favor, intntelo de nuevo.",
"textFormat": "Formato de texto plano",
"jsonFormat": "Formato JSON para desarrolladores"
},
"history": {
"title": "Historial de Clculos",
"totalCalculations": "clculos totales",
"generated": "Generado",
"filteredBy": "Filtrado por",
"reportTitle": "Informe de Historial de Clculos",
"pageNumber": "Pgina",
"morePagesAvailable": "(ms pginas disponibles)",
"optimizationMode": "Optimizacin",
"mode": "Modo",
"dateTime": "Fecha y Hora",
"totalDenominations": "Denominaciones Totales",
"noHistory": "No se encontraron clculos",
"startCalculating": "Comience creando un nuevo clculo",
"date": "Fecha",
"amount": "Cantidad",
"currency": "Moneda",
"notes": "Billetes",
"coins": "Monedas",
"total": "Total",
"timestamp": "Marca de Tiempo",
"actions": "Acciones",
"view": "Ver",
"viewDetails": "Ver Detalles",
"delete": "Eliminar",
"deleteAll": "Eliminar Todo",
"exportAll": "Exportar Todo",
"exportSelected": "Exportar Seleccionados",
"deleteSelected": "Eliminar Seleccionados",
"selected": "seleccionado",
"allCurrencies": "Todas las Monedas",
"confirmDelete": "Est seguro de que desea eliminar este clculo?",
"confirmDeleteSelected": "Eliminar {count} clculos seleccionados?",
"confirmDeleteAll": "Est seguro de que desea eliminar TODOS los {count} clculos de {currency}? Esta accin no se puede deshacer!",
"confirmDeleteAllGlobal": "Est seguro de que desea eliminar TODOS los {count} clculos? Esta accin no se puede deshacer!",
"confirmDeletePermanent": "Esto eliminar permanentemente todos los datos del historial. Est absolutamente seguro?",
"deleteSuccess": "Se eliminaron exitosamente {count} clculo(s)",
"deleteFailed": "Error al eliminar",
"exportFailed": "Error al exportar",
"noHistoryToDelete": "No hay historial para eliminar",
"noHistoryToExport": "No hay datos de historial para exportar",
"loadFailed": "Error al cargar el historial",
"detailsFailed": "Error al cargar los detalles",
"showing": "Mostrando",
"of": "de",
"previous": "Anterior",
"next": "Siguiente",
"copyAll": "Copiar Todo",
"copySelected": "Copiar Seleccionados",
"copiedItems": "{count} elemento(s) copiado(s) al portapapeles!",
"nothingToCopy": "No hay elementos para copiar"
},
"quickAccess": {
"title": "Clculos Recientes",
"recent": "Reciente",
"refresh": "Actualizar",
"noItems": "No hay clculos recientes",
"startUsing": "Comience a usar la calculadora para ver elementos recientes aqu",
"notesCount": "{count} billetes",
"coinsCount": "{count} monedas",
"timeAgo": {
"justNow": "Justo ahora",
"minuteAgo": "Hace 1 minuto",
"minutesAgo": "Hace {count} minutos",
"hourAgo": "Hace 1 hora",
"hoursAgo": "Hace {count} horas",
"dayAgo": "Hace 1 da",
"daysAgo": "Hace {count} das",
"weekAgo": "Hace 1 semana",
"weeksAgo": "Hace {count} semanas",
"monthAgo": "Hace 1 mes",
"monthsAgo": "Hace {count} meses",
"yearAgo": "Hace 1 ao",
"yearsAgo": "Hace {count} aos"
}
},
"settings": {
"title": "Configuracin",
"subtitle": "Personalice las preferencias de su aplicacin",
"general": "Configuracin General",
"appearance": "Apariencia",
"appearanceDesc": "Cambiar entre modo claro y oscuro",
"preferences": "Preferencias",
"data": "Gestin de Datos",
"about": "Acerca de",
"theme": "Tema",
"light": "Claro",
"dark": "Oscuro",
"system": "Sistema",
"language": "Idioma",
"selectLanguage": "Seleccionar idioma",
"languageDesc": "Seleccione su idioma preferido para la interfaz de la aplicacin",
"languageRegion": "Idioma y Regin",
"defaultPreferences": "Preferencias Predeterminadas",
"behavior": "Comportamiento",
"dataSync": "Datos y Sincronizacin",
"defaultCurrency": "Moneda Predeterminada",
"defaultOptimization": "Modo de Optimizacin Predeterminado",
"quickAccess": "Acceso Rpido",
"quickAccessEnabled": "Habilitar Acceso Rpido",
"quickAccessCount": "Cantidad de Acceso Rpido",
"quickAccessCountDesc": "Nmero de clculos recientes a mostrar (5-20)",
"quickAccessDesc": "Mostrar clculos recientes en la barra lateral",
"autoSaveHistory": "Guardar Automticamente en Historial",
"autoSaveDesc": "Guardar clculos automticamente en el historial",
"syncEnabled": "Sincronizacin en la Nube",
"syncDesc": "Sincronizar datos entre dispositivos (prximamente)",
"clearHistory": "Borrar Historial",
"clearHistoryDesc": "Eliminar todo el historial de clculos",
"resetSettings": "Restablecer a Predeterminados",
"resetSettingsDesc": "Restablecer toda la configuracin a valores predeterminados",
"resetToDefaults": "Restablecer a Predeterminados",
"saveChanges": "Guardar Cambios",
"saving": "Guardando...",
"version": "Versin",
"save": "Guardar",
"cancel": "Cancelar",
"saved": "Configuracin guardada exitosamente!",
"error": "Error al guardar la configuracin",
"loadError": "Error al cargar la configuracin",
"quickAccessEnabled_success": "Acceso Rpido habilitado",
"quickAccessDisabled_success": "Acceso Rpido deshabilitado",
"currencyUpdated": "Moneda predeterminada actualizada a {currency}",
"languageUpdated": "Idioma actualizado exitosamente",
"optimizationUpdated": "Modo de optimizacin predeterminado actualizado",
"autoSaveEnabled": "Guardar automticamente en historial habilitado",
"autoSaveDisabled": "Guardar automticamente en historial deshabilitado",
"quickAccessCountError": "La cantidad de Acceso Rpido debe estar entre 5 y 20",
"quickAccessCountUpdated": "Cantidad de Acceso Rpido actualizada a {count}",
"resetConfirm": "Est seguro de que desea restablecer toda la configuracin a los valores predeterminados?",
"resetSuccess": "Configuracin restablecida a valores predeterminados exitosamente!",
"resetError": "Error al restablecer la configuracin",
"dataStorageTitle": "Almacenamiento de Datos",
"dataStorageDesc": "Toda su configuracin e historial de clculos se almacenan localmente en su dispositivo. La sincronizacin en la nube estar disponible en una actualizacin futura."
},
"bulkUpload": {
"title": "Carga masiva CSV",
"subtitle": "Cargue un archivo CSV para procesar mltiples clculos a la vez",
"downloadTemplate": "Descargar plantilla",
"dragDropTitle": "Arrastra y suelta tu archivo CSV aqu",
"dragDropSubtitle": "o haz clic para buscar y seleccionar un archivo",
"selectFile": "Seleccionar archivo",
"removeFile": "Eliminar archivo",
"upload": "Cargar y procesar",
"uploading": "Cargando archivo...",
"processing": "Procesando clculos...",
"pleaseWait": "Esto puede tomar un momento. Por favor espere...",
"uploadAnother": "Cargar otro archivo",
"saveToHistory": "Guardar resultados en el historial",
"requirements": {
"title": "Requisitos del archivo:",
"format": "Formato: CSV (valores separados por comas)",
"columns": "Requerido: amount, currency | Opcional: optimization_mode",
"size": "Tamao mximo de archivo: 10 MB",
"encoding": "Codificacin: UTF-8 recomendado",
"caseInsensitive": "Sin distincin de maysculas: Encabezados y valores pueden estar en cualquier caso (Amount, AMOUNT, amount funcionan)"
},
"errors": {
"invalidFileType": "Tipo de archivo no vlido. Por favor cargue un archivo CSV.",
"fileTooLarge": "El archivo es demasiado grande. El tamao mximo es 10 MB.",
"fileEmpty": "El archivo est vaco. Por favor seleccione un archivo CSV vlido.",
"uploadFailed": "La carga fall. Por favor, intntelo de nuevo."
},
"error": "Error de carga",
"results": {
"totalRows": "Filas totales",
"successful": "Exitoso",
"failed": "Fallido",
"processingTime": "Tiempo de procesamiento",
"rowNumber": "Fila #",
"status": "Estado",
"amount": "Cantidad",
"currency": "Moneda",
"denominations": "Denominaciones",
"details": "Detalles",
"success": "xito",
"error": "Error",
"totalDenom": "Total"
},
"exportCSV": "Exportar CSV",
"exportJSON": "Exportar JSON",
"copyResults": "Copiar resultados",
"copied": "Copiado!"
},
"currencies": {
"INR": "Rupia India (?)",
"USD": "Dlar Estadounidense ($)",
"EUR": "Euro ()",
"GBP": "Libra Esterlina ()",
"JPY": "Yen Japons ()",
"CNY": "Yuan Chino ()",
"AUD": "Dlar Australiano (A$)",
"CAD": "Dlar Canadiense (C$)"
},
"common": {
"yes": "S",
"no": "No",
"ok": "Aceptar",
"cancel": "Cancelar",
"close": "Cerrar",
"save": "Guardar",
"delete": "Eliminar",
"edit": "Editar",
"view": "Ver",
"search": "Buscar",
"filter": "Filtrar",
"clear": "Limpiar",
"loading": "Cargando...",
"error": "Error",
"success": "xito",
"warning": "Advertencia",
"info": "Informacin",
"confirm": "Confirmar"
}
}
?? packages\local-backend\app\locales\fr.json
json
{
"app": {
"title": "Distributeur de Coupures de Monnaie",
"subtitle": "Systme Intelligent de Distribution d'Espces"
},
"nav": {
"calculator": "Calculatrice",
"history": "Historique",
"bulkUpload": "Tlchargement en Masse",
"recent": "Calculs Rcents",
"settings": "Paramtres"
},
"calculator": {
"title": "Calculatrice de Devises",
"subtitle": "Calculez la rpartition des coupures instantanment",
"amount": "Montant",
"amountPlaceholder": "0.00",
"currency": "Devise",
"selectCurrency": "Slectionner la devise",
"calculate": "Calculer la Rpartition",
"calculating": "Calcul en cours...",
"clear": "Effacer",
"reset": "Rinitialiser",
"advancedOptions": "Options Avances",
"optimizationMode": "Mode d'Optimisation",
"greedy": "Gourmand (Minimiser le Total des Coupures)",
"balanced": "quilibr",
"minimizeLarge": "Minimiser les Gros Billets",
"minimizeSmall": "Minimiser les Petits Billets",
"networkError": "Impossible de se connecter au serveur backend. Est-il en cours d'excution ?",
"enterAmount": "Veuillez entrer un montant",
"validAmount": "Veuillez entrer un montant positif valide",
"largeAmountWarning": "Il s'agit d'un montant extrmement lev. Le calcul peut prendre du temps. Continuer?",
"smartCurrency": "Devise Intelligente",
"errors": {
"required": "Le montant est requis",
"positive": "Le montant doit tre positif",
"invalid": "Montant invalide"
}
},
"results": {
"title": "Rsultats de la Rpartition",
"totalAmount": "Montant Total",
"totalNotes": "Billets Totaux",
"totalCoins": "Pices Totales",
"totalDenominations": "Coupures Totales",
"total": "Total",
"breakdown": "Dtails",
"notes": "Billets",
"coins": "Pices",
"denomination": "Coupure",
"type": "Type",
"note": "Billet",
"coin": "Pice",
"count": "Nombre",
"value": "Valeur",
"totalValue": "Valeur Totale",
"export": "Exporter les Rsultats",
"exportCSV": "Exporter en CSV",
"exportPDF": "Exporter en PDF",
"exportWord": "Exporter en Word",
"print": "Imprimer",
"spreadsheetFormat": "Format tableur",
"portableDocument": "Document portable",
"wordFormat": "Format Microsoft Word",
"printPreview": "Aperu et impression",
"noResults": "Aucun rsultat afficher",
"calculate": "Calculez un montant pour voir les rsultats",
"exportFailed": "chec de l'exportation. Veuillez ressayer.",
"allowPopups": "Veuillez autoriser les fentres contextuelles pour exporter",
"copy": "Copier",
"copyResults": "Copier les Rsultats",
"copyAsText": "Copier en Texte",
"copyAsJSON": "Copier en JSON",
"copiedToClipboard": "Copi dans le presse-papiers !",
"copyFailed": "chec de la copie. Veuillez ressayer.",
"textFormat": "Format texte brut",
"jsonFormat": "Format JSON pour dveloppeurs"
},
"history": {
"title": "Historique des Calculs",
"totalCalculations": "calculs totaux",
"generated": "Gnr",
"filteredBy": "Filtr par",
"reportTitle": "Rapport d'Historique des Calculs",
"pageNumber": "Page",
"morePagesAvailable": "(plus de pages disponibles)",
"optimizationMode": "Optimisation",
"mode": "Mode",
"dateTime": "Date et Heure",
"totalDenominations": "Dnominations Totales",
"noHistory": "Aucun calcul trouv",
"startCalculating": "Commencez par crer un nouveau calcul",
"date": "Date",
"amount": "Montant",
"currency": "Devise",
"notes": "Billets",
"coins": "Pices",
"total": "Total",
"timestamp": "Horodatage",
"actions": "Actions",
"view": "Voir",
"viewDetails": "Voir les Dtails",
"delete": "Supprimer",
"deleteAll": "Tout Supprimer",
"exportAll": "Tout Exporter",
"exportSelected": "Exporter la Slection",
"deleteSelected": "Supprimer la Slection",
"selected": "slectionn",
"allCurrencies": "Toutes les Devises",
"confirmDelete": "tes-vous sr de vouloir supprimer ce calcul ?",
"confirmDeleteSelected": "Supprimer {count} calculs slectionns ?",
"confirmDeleteAll": "tes-vous sr de vouloir supprimer TOUS les {count} calculs {currency} ? Cette action ne peut pas tre annule !",
"confirmDeleteAllGlobal": "tes-vous sr de vouloir supprimer TOUS les {count} calculs ? Cette action ne peut pas tre annule !",
"confirmDeletePermanent": "Cela supprimera dfinitivement toutes les donnes de l'historique. tes-vous absolument sr ?",
"deleteSuccess": "{count} calcul(s) supprim(s) avec succs",
"deleteFailed": "chec de la suppression",
"exportFailed": "chec de l'exportation",
"noHistoryToDelete": "Aucun historique supprimer",
"noHistoryToExport": "Aucune donne d'historique exporter",
"loadFailed": "chec du chargement de l'historique",
"detailsFailed": "chec du chargement des dtails",
"showing": "Affichage de",
"of": "sur",
"previous": "Prcdent",
"next": "Suivant",
"copyAll": "Tout Copier",
"copySelected": "Copier la Slection",
"copiedItems": "{count} lment(s) copi(s) dans le presse-papiers !",
"nothingToCopy": "Aucun lment copier"
},
"quickAccess": {
"title": "Calculs Rcents",
"recent": "Rcent",
"refresh": "Actualiser",
"noItems": "Aucun calcul rcent",
"startUsing": "Commencez utiliser la calculatrice pour voir les lments rcents ici",
"notesCount": "{count} billets",
"coinsCount": "{count} pices",
"timeAgo": {
"justNow": " l'instant",
"minuteAgo": "Il y a 1 minute",
"minutesAgo": "Il y a {count} minutes",
"hourAgo": "Il y a 1 heure",
"hoursAgo": "Il y a {count} heures",
"dayAgo": "Il y a 1 jour",
"daysAgo": "Il y a {count} jours",
"weekAgo": "Il y a 1 semaine",
"weeksAgo": "Il y a {count} semaines",
"monthAgo": "Il y a 1 mois",
"monthsAgo": "Il y a {count} mois",
"yearAgo": "Il y a 1 an",
"yearsAgo": "Il y a {count} ans"
}
},
"settings": {
"title": "Paramtres",
"subtitle": "Personnalisez vos prfrences d'application",
"general": "Paramtres Gnraux",
"appearance": "Apparence",
"appearanceDesc": "Basculer entre le mode clair et sombre",
"preferences": "Prfrences",
"data": "Gestion des Donnes",
"about": " Propos",
"theme": "Thme",
"light": "Clair",
"dark": "Sombre",
"system": "Systme",
"language": "Langue",
"selectLanguage": "Slectionner la langue",
"languageDesc": "Slectionnez votre langue prfre pour l'interface de l'application",
"languageRegion": "Langue et Rgion",
"defaultPreferences": "Prfrences par Dfaut",
"behavior": "Comportement",
"dataSync": "Donnes et Synchronisation",
"defaultCurrency": "Devise par Dfaut",
"defaultOptimization": "Mode d'Optimisation par Dfaut",
"quickAccess": "Accs Rapide",
"quickAccessEnabled": "Activer l'Accs Rapide",
"quickAccessCount": "Nombre d'Accs Rapide",
"quickAccessCountDesc": "Nombre de calculs rcents afficher (5-20)",
"quickAccessDesc": "Afficher les calculs rcents dans la barre latrale",
"autoSaveHistory": "Enregistrer Automatiquement dans l'Historique",
"autoSaveDesc": "Enregistrer automatiquement les calculs dans l'historique",
"syncEnabled": "Synchronisation Cloud",
"syncDesc": "Synchroniser les donnes entre les appareils ( venir)",
"clearHistory": "Effacer l'Historique",
"clearHistoryDesc": "Supprimer tout l'historique des calculs",
"resetSettings": "Rinitialiser aux Valeurs par Dfaut",
"resetSettingsDesc": "Rinitialiser tous les paramtres aux valeurs par dfaut",
"resetToDefaults": "Rinitialiser aux Valeurs par Dfaut",
"saveChanges": "Enregistrer les Modifications",
"saving": "Enregistrement...",
"version": "Version",
"save": "Enregistrer",
"cancel": "Annuler",
"saved": "Paramtres enregistrs avec succs !",
"error": "chec de l'enregistrement des paramtres",
"loadError": "chec du chargement des paramtres",
"quickAccessEnabled_success": "Accs Rapide activ",
"quickAccessDisabled_success": "Accs Rapide dsactiv",
"currencyUpdated": "Devise par dfaut mise jour vers {currency}",
"languageUpdated": "Langue mise jour avec succs",
"optimizationUpdated": "Mode d'optimisation par dfaut mis jour",
"autoSaveEnabled": "Enregistrement automatique dans l'historique activ",
"autoSaveDisabled": "Enregistrement automatique dans l'historique dsactiv",
"quickAccessCountError": "Le nombre d'Accs Rapide doit tre entre 5 et 20",
"quickAccessCountUpdated": "Nombre d'Accs Rapide mis jour {count}",
"resetConfirm": "tes-vous sr de vouloir rinitialiser tous les paramtres aux valeurs par dfaut ?",
"resetSuccess": "Paramtres rinitialiss aux valeurs par dfaut avec succs !",
"resetError": "chec de la rinitialisation des paramtres",
"dataStorageTitle": "Stockage des Donnes",
"dataStorageDesc": "Tous vos paramtres et votre historique de calculs sont stocks localement sur votre appareil. La synchronisation cloud sera disponible dans une future mise jour."
},
"bulkUpload": {
"title": "Tlchargement CSV en masse",
"subtitle": "Tlchargez un fichier CSV pour traiter plusieurs calculs la fois",
"downloadTemplate": "Tlcharger le modle",
"dragDropTitle": "Glissez-dposez votre fichier CSV ici",
"dragDropSubtitle": "ou cliquez pour parcourir et slectionner un fichier",
"selectFile": "Slectionner un fichier",
"removeFile": "Supprimer le fichier",
"upload": "Tlcharger et traiter",
"uploading": "Tlchargement du fichier...",
"processing": "Traitement des calculs...",
"pleaseWait": "Cela peut prendre un moment. Veuillez patienter...",
"uploadAnother": "Tlcharger un autre fichier",
"saveToHistory": "Enregistrer les rsultats dans l'historique",
"requirements": {
"title": "Exigences du fichier:",
"format": "Format: CSV (valeurs spares par des virgules)",
"columns": "Requis: amount, currency | Optionnel: optimization_mode",
"size": "Taille maximale du fichier: 10 Mo",
"encoding": "Encodage: UTF-8 recommand",
"caseInsensitive": "Insensible la casse: Les en-ttes et valeurs peuvent tre dans n'importe quel cas (Amount, AMOUNT, amount fonctionnent)"
},
"errors": {
"invalidFileType": "Type de fichier non valide. Veuillez tlcharger un fichier CSV.",
"fileTooLarge": "Le fichier est trop volumineux. La taille maximale est de 10 Mo.",
"fileEmpty": "Le fichier est vide. Veuillez slectionner un fichier CSV valide.",
"uploadFailed": "Le tlchargement a chou. Veuillez ressayer."
},
"error": "Erreur de tlchargement",
"results": {
"totalRows": "Lignes totales",
"successful": "Russi",
"failed": "chou",
"processingTime": "Temps de traitement",
"rowNumber": "Ligne #",
"status": "Statut",
"amount": "Montant",
"currency": "Devise",
"denominations": "Dnominations",
"details": "Dtails",
"success": "Succs",
"error": "Erreur",
"totalDenom": "Total"
},
"exportCSV": "Exporter CSV",
"exportJSON": "Exporter JSON",
"copyResults": "Copier les rsultats",
"copied": "Copi!"
},
"currencies": {
"INR": "Roupie Indienne (?)",
"USD": "Dollar Amricain ($)",
"EUR": "Euro ()",
"GBP": "Livre Sterling ()",
"JPY": "Yen Japonais ()",
"CNY": "Yuan Chinois ()",
"AUD": "Dollar Australien (A$)",
"CAD": "Dollar Canadien (C$)"
},
"common": {
"yes": "Oui",
"no": "Non",
"ok": "D'accord",
"cancel": "Annuler",
"close": "Fermer",
"save": "Enregistrer",
"delete": "Supprimer",
"edit": "Modifier",
"view": "Voir",
"search": "Rechercher",
"filter": "Filtrer",
"clear": "Effacer",
"loading": "Chargement...",
"error": "Erreur",
"success": "Succs",
"warning": "Avertissement",
"info": "Information",
"confirm": "Confirmer"
}
}
?? packages\local-backend\app\locales\hi.json
json
{
"app": {
"title": "मुद्रा मूल्यवर्ग वितरक",
"subtitle": "स्मार्ट नकद वितरण प्रणाली"
},
"nav": {
"calculator": "कैल्कुलेटर",
"history": "इतिहास",
"bulkUpload": "बल्क अपलोड",
"recent": "हाल की गणनाएँ",
"settings": "सेटिंग्स"
},
"calculator": {
"title": "मुद्रा कैलकुलेटर",
"subtitle": "तुरंत मूल्यवर्ग विभाजन की गणना करें",
"amount": "राशि",
"amountPlaceholder": "0.00",
"currency": "मुद्रा",
"selectCurrency": "मुद्रा चुनें",
"calculate": "विभाजन की गणना करें",
"calculating": "गणना हो रही है...",
"clear": "साफ़ करें",
"reset": "रीसेट करें",
"advancedOptions": "उन्नत विकल्प",
"optimizationMode": "अनुकूलन मोड",
"greedy": "ग्रीडी (कुल मूल्यवर्ग कम करें)",
"balanced": "संतुलित",
"minimizeLarge": "बड़े नोट कम करें",
"minimizeSmall": "छोटे नोट कम करें",
"networkError": "बैकएंड सर्वर से कनेक्ट नहीं हो सका। क्या यह चल रहा है?",
"enterAmount": "कृपया राशि दर्ज करें",
"validAmount": "कृपया वैध धनात्मक राशि दर्ज करें",
"largeAmountWarning": "यह बहुत बड़ी राशि है। गणना में कुछ समय लग सकता है। जारी रखें?",
"smartCurrency": "स्मार्ट मुद्रा",
"errors": {
"required": "राशि आवश्यक है",
"positive": "राशि सकारात्मक होनी चाहिए",
"invalid": "अमान्य राशि"
}
},
"results": {
"title": "विवरण परिणाम",
"totalAmount": "कुल राशि",
"totalNotes": "कुल नोट",
"totalCoins": "कुल सिक्के",
"totalDenominations": "कुल मूल्यवर्ग",
"total": "कुल",
"breakdown": "विवरण",
"notes": "नोट",
"coins": "सिक्के",
"denomination": "मूल्यवर्ग",
"type": "प्रकार",
"note": "नोट",
"coin": "सिक्का",
"count": "संख्या",
"value": "मूल्य",
"totalValue": "कुल मूल्य",
"export": "परिणाम निर्यात करें",
"exportCSV": "CSV के रूप में निर्यात करें",
"exportPDF": "PDF के रूप में निर्यात करें",
"exportWord": "Word के रूप में निर्यात करें",
"print": "प्रिंट करें",
"spreadsheetFormat": "स्प्रेडशीट प्रारूप",
"portableDocument": "पोर्टेबल दस्तावेज़",
"wordFormat": "Microsoft Word प्रारूप",
"printPreview": "प्रिंट पूर्वावलोकन और प्रिंट करें",
"noResults": "प्रदर्शित करने के लिए कोई परिणाम नहीं",
"calculate": "परिणाम देखने के लिए राशि की गणना करें",
"exportFailed": "निर्यात विफल रहा। कृपया पुनः प्रयास करें।",
"allowPopups": "निर्यात करने के लिए कृपया पॉपअप की अनुमति दें",
"copy": "कॉपी करें",
"copyResults": "परिणाम कॉपी करें",
"copyAsText": "टेक्स्ट के रूप में कॉपी करें",
"copyAsJSON": "JSON के रूप में कॉपी करें",
"copiedToClipboard": "क्लिपबोर्ड पर कॉपी किया गया!",
"copyFailed": "कॉपी करने में विफल। कृपया पुनः प्रयास करें।",
"textFormat": "सादा टेक्स्ट प्रारूप",
"jsonFormat": "डेवलपर्स के लिए JSON प्रारूप"
},
"history": {
"title": "गणना इतिहास",
"totalCalculations": "कुल गणनाएं",
"generated": "उत्पन्न",
"filteredBy": "द्वारा फ़िल्टर किया गया",
"reportTitle": "गणना इतिहास रिपोर्ट",
"pageNumber": "पृष्ठ",
"morePagesAvailable": "(अधिक पृष्ठ उपलब्ध हैं)",
"optimizationMode": "अनुकूलन",
"mode": "मोड",
"dateTime": "दिनांक और समय",
"totalDenominations": "कुल मूल्यवर्ग",
"noHistory": "कोई गणना नहीं मिली",
"startCalculating": "नई गणना बनाकर शुरू करें",
"date": "तारीख",
"amount": "राशि",
"currency": "मुद्रा",
"notes": "नोट",
"coins": "सिक्के",
"total": "कुल",
"timestamp": "समय",
"actions": "क्रियाएं",
"view": "देखें",
"viewDetails": "विवरण देखें",
"delete": "हटाएं",
"deleteAll": "सभी हटाएं",
"exportAll": "सभी निर्यात करें",
"exportSelected": "चयनित निर्यात करें",
"deleteSelected": "चयनित हटाएं",
"selected": "चयनित",
"allCurrencies": "सभी मुद्राएं",
"confirmDelete": "क्या आप वाकई इस गणना को हटाना चाहते हैं?",
"confirmDeleteSelected": "{count} चयनित गणनाएं हटाएं?",
"confirmDeleteAll": "क्या आप वाकई सभी {count} {currency} गणनाएं हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती!",
"confirmDeleteAllGlobal": "क्या आप वाकई सभी {count} गणनाएं हटाना चाहते हैं? यह क्रिया पूर्ववत नहीं की जा सकती!",
"confirmDeletePermanent": "यह सभी इतिहास डेटा को स्थायी रूप से हटा देगा। क्या आप पूरी तरह सुनिश्चित हैं?",
"deleteSuccess": "सफलतापूर्वक {count} गणना(एं) हटाई गई",
"deleteFailed": "हटाने में विफल",
"exportFailed": "निर्यात विफल",
"noHistoryToDelete": "हटाने के लिए कोई इतिहास नहीं",
"noHistoryToExport": "निर्यात करने के लिए कोई इतिहास डेटा नहीं",
"loadFailed": "इतिहास लोड करने में विफल",
"detailsFailed": "विवरण लोड करने में विफल",
"showing": "दिखा रहा है",
"of": "में से",
"previous": "पिछला",
"next": "अगला",
"copyAll": "सभी कॉपी करें",
"copySelected": "चयनित कॉपी करें",
"copiedItems": "{count} आइटम क्लिपबोर्ड पर कॉपी किए गए!",
"nothingToCopy": "कॉपी करने के लिए कोई आइटम नहीं"
},
"quickAccess": {
"title": "हाल की गणनाएं",
"recent": "हाल का",
"refresh": "रीफ्रेश करें",
"noItems": "कोई हालिया गणना नहीं",
"startUsing": "यहां हाल की वस्तुओं को देखने के लिए कैलकुलेटर का उपयोग शुरू करें",
"notesCount": "{count} नोट",
"coinsCount": "{count} सिक्के",
"timeAgo": {
"justNow": "अभी",
"minuteAgo": "1 मिनट पहले",
"minutesAgo": "{count} मिनट पहले",
"hourAgo": "1 घंटे पहले",
"hoursAgo": "{count} घंटे पहले",
"dayAgo": "1 दिन पहले",
"daysAgo": "{count} दिन पहले",
"weekAgo": "1 सप्ताह पहले",
"weeksAgo": "{count} सप्ताह पहले",
"monthAgo": "1 महीने पहले",
"monthsAgo": "{count} महीने पहले",
"yearAgo": "1 साल पहले",
"yearsAgo": "{count} साल पहले"
}
},
"settings": {
"title": "सेटिंग्स",
"subtitle": "अपनी एप्लिकेशन प्राथमिकताओं को अनुकूलित करें",
"general": "सामान्य सेटिंग्स",
"appearance": "दिखावट",
"appearanceDesc": "हल्के और गहरे मोड के बीच स्विच करें",
"preferences": "प्राथमिकताएं",
"data": "डेटा प्रबंधन",
"about": "के बारे में",
"theme": "थीम",
"light": "हल्का",
"dark": "गहरा",
"system": "सिस्टम",
"language": "भाषा",
"selectLanguage": "भाषा चुनें",
"languageDesc": "एप्लिकेशन इंटरफ़ेस के लिए अपनी पसंदीदा भाषा चुनें",
"languageRegion": "भाषा और क्षेत्र",
"defaultPreferences": "डिफ़ॉल्ट प्राथमिकताएं",
"behavior": "व्यवहार",
"dataSync": "डेटा और सिंक",
"defaultCurrency": "डिफ़ॉल्ट मुद्रा",
"defaultOptimization": "डिफ़ॉल्ट अनुकूलन मोड",
"quickAccess": "त्वरित पहुंच",
"quickAccessEnabled": "त्वरित पहुंच सक्षम करें",
"quickAccessCount": "त्वरित पहुंच संख्या",
"quickAccessCountDesc": "दिखाने के लिए हाल की गणनाओं की संख्या (5-20)",
"quickAccessDesc": "साइडबार में हाल की गणनाएं दिखाएं",
"autoSaveHistory": "इतिहास में स्वतः सहेजें",
"autoSaveDesc": "स्वचालित रूप से गणनाओं को इतिहास में सहेजें",
"syncEnabled": "क्लाउड सिंक",
"syncDesc": "उपकरणों में डेटा सिंक करें (जल्द आ रहा है)",
"clearHistory": "इतिहास साफ़ करें",
"clearHistoryDesc": "सभी गणना इतिहास हटाएं",
"resetSettings": "डिफ़ॉल्ट पर रीसेट करें",
"resetSettingsDesc": "सभी सेटिंग्स को डिफ़ॉल्ट मानों पर रीसेट करें",
"resetToDefaults": "डिफ़ॉल्ट पर रीसेट करें",
"saveChanges": "परिवर्तन सहेजें",
"saving": "सहेजा जा रहा है...",
"version": "संस्करण",
"save": "सहेजें",
"cancel": "रद्द करें",
"saved": "सेटिंग्स सफलतापूर्वक सहेजी गईं!",
"error": "सेटिंग्स सहेजने में विफल",
"loadError": "सेटिंग्स लोड करने में विफल",
"quickAccessEnabled_success": "त्वरित पहुंच सक्षम",
"quickAccessDisabled_success": "त्वरित पहुंच अक्षम",
"currencyUpdated": "डिफ़ॉल्ट मुद्रा {currency} में अपडेट की गई",
"languageUpdated": "भाषा सफलतापूर्वक अपडेट की गई",
"optimizationUpdated": "डिफ़ॉल्ट अनुकूलन मोड अपडेट किया गया",
"autoSaveEnabled": "इतिहास में स्वतः सहेजें सक्षम",
"autoSaveDisabled": "इतिहास में स्वतः सहेजें अक्षम",
"quickAccessCountError": "त्वरित पहुंच संख्या 5 और 20 के बीच होनी चाहिए",
"quickAccessCountUpdated": "त्वरित पहुंच संख्या {count} में अपडेट की गई",
"resetConfirm": "क्या आप वाकई सभी सेटिंग्स को डिफ़ॉल्ट पर रीसेट करना चाहते हैं?",
"resetSuccess": "सेटिंग्स सफलतापूर्वक डिफ़ॉल्ट पर रीसेट की गईं!",
"resetError": "सेटिंग्स रीसेट करने में विफल",
"dataStorageTitle": "डेटा संग्रहण",
"dataStorageDesc": "आपकी सभी सेटिंग्स और गणना इतिहास आपके डिवाइस पर स्थानीय रूप से संग्रहीत हैं। क्लाउड सिंक भविष्य के अपडेट में उपलब्ध होगा।"
},
"bulkUpload": {
"title": "बल्क CSV अपलोड",
"subtitle": "एक साथ कई गणनाओं को संसाधित करने के लिए CSV फ़ाइल अपलोड करें",
"downloadTemplate": "टेम्पलेट डाउनलोड करें",
"dragDropTitle": "अपनी CSV फ़ाइल यहां खींचें और छोड़ें",
"dragDropSubtitle": "या ब्राउज़ करने और फ़ाइल चुनने के लिए क्लिक करें",
"selectFile": "फ़ाइल चुनें",
"removeFile": "फ़ाइल हटाएं",
"upload": "अपलोड और प्रोसेस करें",
"uploading": "फ़ाइल अपलोड हो रही है...",
"processing": "गणना प्रोसेस हो रही है...",
"pleaseWait": "इसमें कुछ समय लग सकता है। कृपया प्रतीक्षा करें...",
"uploadAnother": "दूसरी फ़ाइल अपलोड करें",
"saveToHistory": "परिणाम इतिहास में सहेजें",
"requirements": {
"title": "फ़ाइल आवश्यकताएं:",
"format": "प्रारूप: CSV (अल्पविराम-पृथक मान)",
"columns": "आवश्यक: amount, currency | वैकल्पिक: optimization_mode",
"size": "अधिकतम फ़ाइल आकार: 10 MB",
"encoding": "एन्कोडिंग: UTF-8 अनुशंसित",
"caseInsensitive": "केस-असंवेदी: कॉलम हेडर और मान किसी भी केस में हो सकते हैं (Amount, AMOUNT, amount सभी काम करते हैं)"
},
"errors": {
"invalidFileType": "अमान्य फ़ाइल प्रकार। कृपया CSV फ़ाइल अपलोड करें।",
"fileTooLarge": "फ़ाइल बहुत बड़ी है। अधिकतम आकार 10 MB है।",
"fileEmpty": "फ़ाइल खाली है। कृपया एक मान्य CSV फ़ाइल चुनें।",
"uploadFailed": "अपलोड विफल रहा। कृपया पुनः प्रयास करें।"
},
"error": "अपलोड त्रुटि",
"results": {
"totalRows": "कुल पंक्तियां",
"successful": "सफल",
"failed": "विफल",
"processingTime": "प्रोसेसिंग समय",
"rowNumber": "पंक्ति #",
"status": "स्थिति",
"amount": "राशि",
"currency": "मुद्रा",
"denominations": "मूल्यवर्ग",
"details": "विवरण",
"success": "सफलता",
"error": "त्रुटि",
"totalDenom": "कुल"
},
"exportCSV": "CSV निर्यात करें",
"exportJSON": "JSON निर्यात करें",
"copyResults": "परिणाम कॉपी करें",
"copied": "कॉपी हो गया!"
},
"currencies": {
"INR": "भारतीय रुपया (?)",
"USD": "अमेरिकी डॉलर ($)",
"EUR": "यूरो ()",
"GBP": "ब्रिटिश पाउंड ()",
"JPY": "जापानी येन ()",
"CNY": "चीनी युआन ()",
"AUD": "ऑस्ट्रेलियाई डॉलर (A$)",
"CAD": "कैनेडियन डॉलर (C$)"
},
"common": {
"yes": "हाँ",
"no": "नहीं",
"ok": "ठीक है",
"cancel": "रद्द करें",
"close": "बंद करें",
"save": "सहेजें",
"delete": "हटाएं",
"edit": "संपादित करें",
"view": "देखें",
"search": "खोजें",
"filter": "फ़िल्टर",
"clear": "साफ़ करें",
"loading": "लोड हो रहा है...",
"error": "त्रुटि",
"success": "सफलता",
"warning": "चेतावनी",
"info": "जानकारी",
"confirm": "पुष्टि करें"
}
}
services/
?? packages\local-backend\app\services\ocr_processor.py
python
"""
OCR Processing Service for Bulk Upload - Rebuilt from Scratch
Handles text extraction from various file formats:
- CSV files (direct parsing, no OCR needed)
- Images (JPG, PNG, TIFF, BMP) - Tesseract OCR
- PDFs (text extraction + OCR for scanned PDFs) - PyMuPDF + pdf2image + Tesseract
- Word documents (.docx) - python-docx
Fully offline after dependencies are installed.
"""
import os
import re
import io
import tempfile
from pathlib import Path
from typing import List, Dict, Any, Optional
from decimal import Decimal, InvalidOperation
import logging
# Configure logging
logger = logging.getLogger(__name__)
# Import optional OCR dependencies
try:
import pytesseract
from PIL import Image
HAS_TESSERACT = True
except ImportError:
HAS_TESSERACT = False
logger.warning("Tesseract OCR not available")
try:
import fitz # PyMuPDF
HAS_PYMUPDF = True
except ImportError:
HAS_PYMUPDF = False
logger.warning("PyMuPDF not available")
try:
from pdf2image import convert_from_bytes
HAS_PDF2IMAGE = True
except ImportError:
HAS_PDF2IMAGE = False
logger.warning("pdf2image not available")
try:
import docx
HAS_DOCX = True
except ImportError:
HAS_DOCX = False
logger.warning("python-docx not available")
class OCRProcessor:
"""
Handles OCR and text extraction from multiple file formats.
Enhanced with intelligent parsing and smart defaults.
"""
def __init__(self, default_currency: str = 'INR', default_mode: str = 'greedy'):
"""Initialize OCR processor with defaults."""
self.supported_image_formats = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.bmp', '.gif', '.webp'}
self.supported_pdf_formats = {'.pdf'}
self.supported_word_formats = {'.docx', '.doc'}
# Smart defaults
self.default_currency = default_currency
self.default_mode = default_mode
logger.info(f"OCR Processor initialized (default currency: {default_currency}, default mode: {default_mode})")
def check_dependencies(self) -> Dict[str, bool]:
"""Check which OCR dependencies are available."""
return {
'tesseract': HAS_TESSERACT,
'pymupdf': HAS_PYMUPDF,
'pdf2image': HAS_PDF2IMAGE,
'docx': HAS_DOCX
}
def process_file(self, file_data: bytes, filename: str) -> List[Dict[str, Any]]:
"""
Process uploaded file and extract structured data.
Args:
file_data: Raw file bytes
filename: Original filename
Returns:
List of dicts with keys: row_number, amount, currency, optimization_mode
Raises:
ValueError: If file format not supported or extraction fails
"""
file_ext = Path(filename).suffix.lower()
logger.info(f"Processing file: {filename} (size: {len(file_data)} bytes, type: {file_ext})")
# Route to appropriate processor based on file type
if file_ext in self.supported_image_formats:
return self._process_image(file_data, filename)
elif file_ext in self.supported_pdf_formats:
return self._process_pdf(file_data, filename)
elif file_ext in self.supported_word_formats:
return self._process_word(file_data, filename)
else:
raise ValueError(f"Unsupported file format: {file_ext}")
def _process_image(self, file_data: bytes, filename: str) -> List[Dict[str, Any]]:
"""Extract text from image using Tesseract OCR."""
if not HAS_TESSERACT:
raise ValueError("Tesseract OCR not installed. Run: install_ocr_simple.ps1")
try:
# Load image from bytes
image = Image.open(io.BytesIO(file_data))
logger.info(f"Image loaded: {image.size}, mode: {image.mode}")
# Perform OCR
extracted_text = pytesseract.image_to_string(image)
logger.debug(f"Extracted text ({len(extracted_text)} chars):\n{extracted_text[:500]}")
# Parse extracted text into structured rows
return self._parse_text_to_rows(extracted_text)
except Exception as e:
logger.error(f"Image OCR failed: {str(e)}")
raise ValueError(f"Failed to process image: {str(e)}")
def _process_pdf(self, file_data: bytes, filename: str) -> List[Dict[str, Any]]:
"""Extract text from PDF (text-based or scanned)."""
if not HAS_PYMUPDF:
raise ValueError("PyMuPDF not installed. Run: install_ocr_simple.ps1")
try:
# Try text extraction first (faster for text-based PDFs)
pdf_document = fitz.open(stream=file_data, filetype="pdf")
extracted_text = ""
for page_num in range(len(pdf_document)):
page = pdf_document[page_num]
extracted_text += page.get_text()
pdf_document.close()
logger.debug(f"PDF text extracted ({len(extracted_text)} chars)")
# If no text extracted, try OCR on scanned PDF
if len(extracted_text.strip()) < 50:
logger.info("PDF appears to be scanned, attempting OCR")
return self._process_scanned_pdf(file_data, filename)
# Parse extracted text
return self._parse_text_to_rows(extracted_text)
except Exception as e:
logger.error(f"PDF processing failed: {str(e)}")
raise ValueError(f"Failed to process PDF: {str(e)}")
def _process_scanned_pdf(self, file_data: bytes, filename: str) -> List[Dict[str, Any]]:
"""Process scanned PDF using OCR."""
if not HAS_PDF2IMAGE or not HAS_TESSERACT:
raise ValueError("pdf2image or Tesseract not installed for scanned PDFs")
try:
# Convert PDF pages to images
images = convert_from_bytes(file_data)
logger.info(f"Converted PDF to {len(images)} images")
extracted_text = ""
for idx, image in enumerate(images):
logger.debug(f"OCR on page {idx + 1}/{len(images)}")
page_text = pytesseract.image_to_string(image)
extracted_text += page_text + "\n"
logger.debug(f"Total extracted text: {len(extracted_text)} chars")
return self._parse_text_to_rows(extracted_text)
except Exception as e:
logger.error(f"Scanned PDF OCR failed: {str(e)}")
raise ValueError(f"Failed to OCR scanned PDF: {str(e)}")
def _process_word(self, file_data: bytes, filename: str) -> List[Dict[str, Any]]:
"""Extract text from Word document."""
if not HAS_DOCX:
raise ValueError("python-docx not installed. Run: install_ocr_simple.ps1")
try:
# Load Word document from bytes
doc = docx.Document(io.BytesIO(file_data))
# Extract all text from paragraphs
extracted_text = "\n".join([para.text for para in doc.paragraphs])
# Also extract text from tables
for table in doc.tables:
for row in table.rows:
row_text = " | ".join([cell.text for cell in row.cells])
extracted_text += "\n" + row_text
logger.debug(f"Word document text extracted ({len(extracted_text)} chars)")
return self._parse_text_to_rows(extracted_text)
except Exception as e:
logger.error(f"Word document processing failed: {str(e)}")
raise ValueError(f"Failed to process Word document: {str(e)}")
def _parse_text_to_rows(self, text: str) -> List[Dict[str, Any]]:
"""
Parse extracted text into structured rows.
Expected format examples:
1. CSV-like: "125.50, USD, greedy"
2. Tabular: "Amount | Currency | Mode"
3. Natural: "Amount: 125.50 Currency: USD Mode: greedy"
"""
rows = []
lines = text.strip().split('\n')
logger.info(f"Parsing {len(lines)} lines of text")
# Detect header row (skip it)
header_keywords = ['amount', 'currency', 'mode', 'optimization']
for line_num, line in enumerate(lines, start=1):
line = line.strip()
if not line:
continue
# Skip header rows
if any(keyword in line.lower() for keyword in header_keywords):
if line_num == 1 or '|' in line or '-' * 3 in line:
logger.debug(f"Skipping header line {line_num}: {line}")
continue
# Try to parse the line
try:
parsed_row = self._parse_line(line, line_num)
if parsed_row:
rows.append(parsed_row)
logger.debug(f"Line {line_num} parsed: {parsed_row}")
except Exception as e:
logger.warning(f"Failed to parse line {line_num}: {line} - {str(e)}")
continue
logger.info(f"Successfully parsed {len(rows)} rows from {len(lines)} lines")
return rows
def _parse_line(self, line: str, line_number: int) -> Optional[Dict[str, Any]]:
"""
ENHANCED: Parse a single line with intelligent extraction and smart defaults.
Handles ANY format:
- CSV: "125.50, USD, greedy" or "125.50, USD" or "125.50"
- Pipe: "125.50 | USD | greedy"
- Tabular: "125.50 USD greedy"
- Natural: "Amount: 125.50 Currency: USD Mode: greedy"
- Mixed: "125.50 USD" or "1000 INR greedy" or just "5000"
Smart Defaults:
- Missing currency → uses system default
- Missing mode → uses 'greedy'
"""
# Extract amount first (required)
amount = self._smart_extract_amount(line)
if not amount:
return None # No valid amount found
# Extract currency (optional, defaults to system default)
currency = self._smart_extract_currency(line)
if not currency:
currency = self.default_currency
logger.debug(f"Line {line_number}: No currency found, using default: {currency}")
# Extract mode (optional, defaults to greedy)
mode = self._smart_extract_mode(line)
if not mode:
mode = self.default_mode
logger.debug(f"Line {line_number}: No mode found, using default: {mode}")
return {
'row_number': line_number,
'amount': amount,
'currency': currency,
'optimization_mode': mode
}
def _smart_extract_amount(self, text: str) -> str:
"""ENHANCED: Intelligently extract amount from any text format."""
# Strategy 1: Look for explicit amount labels
amount_match = re.search(r'(?:amount|amt|value|price|total)[:\s]*([0-9.,E+-]+)', text, re.IGNORECASE)
if amount_match:
return self._clean_amount(amount_match.group(1))
# Strategy 2: Find first number in the line (most common)
number_match = re.search(r'([0-9.,E+-]+)', text)
if number_match:
return self._clean_amount(number_match.group(1))
return ''
def _clean_amount(self, text: str) -> str:
"""Clean and normalize amount string."""
# Remove currency symbols and extra whitespace
cleaned = re.sub(r'[?$,\s]', '', text)
# Handle scientific notation (e.g., 1.23E+10)
if 'E' in cleaned.upper():
try:
float_val = float(cleaned)
return str(float_val)
except ValueError:
pass
return cleaned
def _extract_amount(self, text: str) -> str:
"""Legacy method - redirects to smart extraction."""
return self._smart_extract_amount(text)
def _smart_extract_currency(self, text: str) -> str:
"""ENHANCED: Intelligently extract currency from any text format."""
# Strategy 1: Look for currency symbols and names first (most specific)
text_lower = text.lower()
if '?' in text or 'rs.' in text_lower or 'rupee' in text_lower:
return 'INR'
if '#039; in text or 'dollar' in text_lower:
return 'USD'
if '' in text or 'euro' in text_lower:
return 'EUR'
if '' in text or 'pound' in text_lower or 'sterling' in text_lower:
return 'GBP'
# Strategy 2: Look for explicit currency labels
currency_label_match = re.search(r'(?:currency|cur)[:\s]*([A-Z]{3}|\w+)', text, re.IGNORECASE)
if currency_label_match:
return self._normalize_currency(currency_label_match.group(1))
# Strategy 3: Look for 3-letter currency codes anywhere
currency_code_match = re.search(r'\b([A-Z]{3})\b', text.upper())
if currency_code_match:
code = currency_code_match.group(1)
# Filter out common non-currency 3-letter words
if code not in ['THE', 'AND', 'FOR', 'ARE', 'YOU', 'NOT', 'BUT', 'CAN', 'ALL']:
return self._normalize_currency(code)
return '' # No currency found, will use default
def _normalize_currency(self, text: str) -> str:
"""Normalize currency names and codes."""
# Common currency name corrections
corrections = {
'RUPEE': 'INR', 'RUPEES': 'INR', 'RS': 'INR', 'INDIAN': 'INR',
'DOLLAR': 'USD', 'DOLLARS': 'USD', 'BUCK': 'USD', 'BUCKS': 'USD',
'EURO': 'EUR', 'EUROS': 'EUR',
'POUND': 'GBP', 'POUNDS': 'GBP', 'STERLING': 'GBP',
'YEN': 'JPY', 'JAPANESE': 'JPY',
'YUAN': 'CNY', 'RENMINBI': 'CNY',
'CANADIAN': 'CAD', 'LOONIE': 'CAD'
}
text_upper = text.upper().strip()
return corrections.get(text_upper, text_upper if len(text_upper) == 3 else '')
def _extract_currency(self, text: str) -> str:
"""Legacy method - redirects to smart extraction."""
return self._smart_extract_currency(text)
def _smart_extract_mode(self, text: str) -> str:
"""ENHANCED: Intelligently extract optimization mode from any text format."""
if not text:
return '' # Will use default
text_lower = text.lower().strip()
# Strategy 1: Look for explicit mode labels
mode_label_match = re.search(r'(?:mode|method|optimization|opt|strategy)[:\s]*(\w+)', text_lower)
if mode_label_match:
mode_text = mode_label_match.group(1)
return self._normalize_mode(mode_text)
# Strategy 2: Look for mode keywords anywhere in text
return self._normalize_mode(text_lower)
def _normalize_mode(self, text: str) -> str:
"""Normalize mode text to valid optimization mode."""
if not text:
return ''
text_lower = text.lower().strip()
# Valid modes
valid_modes = ['greedy', 'balanced', 'minimize_large', 'minimize_small']
# Direct match
if text_lower in valid_modes:
return text_lower
# Partial matches and aliases
if 'bal' in text_lower or 'even' in text_lower or 'equal' in text_lower:
return 'balanced'
if 'large' in text_lower or 'big' in text_lower or 'max' in text_lower:
return 'minimize_large'
if 'small' in text_lower or 'little' in text_lower or 'tiny' in text_lower:
return 'minimize_small'
if 'greed' in text_lower or 'fast' in text_lower or 'quick' in text_lower:
return 'greedy'
return '' # No mode found, will use default
def _extract_mode(self, text: str) -> str:
"""Legacy method - redirects to smart extraction."""
result = self._smart_extract_mode(text)
return result if result else self.default_mode
def _looks_like_amount(self, text: str) -> bool:
"""Check if text looks like a numeric amount."""
# Remove common separators
cleaned = text.replace(',', '').replace(' ', '').replace('?', '').replace('#039;, '')
# Check if it's a number (including scientific notation)
try:
float(cleaned)
return True
except ValueError:
return False
# Singleton instance
_ocr_processor_instance = None
def get_ocr_processor() -> OCRProcessor:
"""Get singleton OCR processor instance."""
global _ocr_processor_instance
if _ocr_processor_instance is None:
_ocr_processor_instance = OCRProcessor()
return _ocr_processor_instance
?? packages\local-backend\app\__init__.py
python
"""
App package initialization.
"""
__version__ = "1.0.0"
?? packages\local-backend\app\config.py
python
"""
Configuration settings for local backend.
"""
from pydantic_settings import BaseSettings
from pathlib import Path
from typing import Optional
class Settings(BaseSettings):
"""Application settings."""
# Application
APP_NAME: str = "Currency Denomination System - Local Backend"
VERSION: str = "1.0.0"
DEBUG: bool = True
# Database
LOCAL_DB_PATH: Path = Path("./data/local.db")
# Cloud sync
SYNC_ENABLED: bool = True
CLOUD_API_URL: Optional[str] = "http://localhost:8000"
SYNC_INTERVAL_MINUTES: int = 30
# Export
EXPORT_DIR: Path = Path("./exports")
MAX_EXPORT_SIZE_MB: int = 100
# History
MAX_HISTORY_ITEMS: int = 10000
QUICK_ACCESS_COUNT: int = 10
# Bulk processing
MAX_BULK_ROWS: int = 100000
BULK_BATCH_SIZE: int = 1000
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()
# Ensure directories exist
settings.LOCAL_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
settings.EXPORT_DIR.mkdir(parents=True, exist_ok=True)
?? packages\local-backend\app\database.py
python
"""
Database models and initialization.
"""
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, DECIMAL
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from datetime import datetime, timezone
from app.config import settings
# Create engine
DATABASE_URL = f"sqlite:///{settings.LOCAL_DB_PATH}"
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False}, # Needed for SQLite
echo=settings.DEBUG
)
# Session
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# Base class
Base = declarative_base()
class Calculation(Base):
"""Calculation history table."""
__tablename__ = "calculations"
id = Column(Integer, primary_key=True, index=True)
amount = Column(String, nullable=False) # Store as string to preserve precision
currency = Column(String(3), nullable=False)
# Source/target for FX
source_currency = Column(String(3), nullable=True)
target_currency = Column(String(3), nullable=True)
exchange_rate = Column(String, nullable=True)
# Optimization
optimization_mode = Column(String(50), default="greedy")
constraints = Column(Text, nullable=True) # JSON string
# Result
result = Column(Text, nullable=False) # JSON string
total_notes = Column(String, default="0") # Store as string for large numbers
total_coins = Column(String, default="0") # Store as string for large numbers
total_denominations = Column(String, default="0") # Store as string for large numbers
# Metadata
source = Column(String(20), default="desktop") # desktop/mobile/api
synced = Column(Boolean, default=False)
cloud_id = Column(String, nullable=True) # ID in cloud database
# Timestamps
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class UserSetting(Base):
"""User settings table."""
__tablename__ = "user_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False)
value = Column(Text, nullable=False)
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
class ExportRecord(Base):
"""Export history table."""
__tablename__ = "export_records"
id = Column(Integer, primary_key=True, index=True)
export_type = Column(String(20), nullable=False) # csv, excel, pdf
file_path = Column(String, nullable=False)
item_count = Column(Integer, default=0)
file_size_bytes = Column(Integer, default=0)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
async def init_db():
"""Initialize database - create tables."""
Base.metadata.create_all(bind=engine)
def get_db():
"""Get database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
?? packages\local-backend\app\main.py
python
"""
Local Backend API - FastAPI Application
This is the offline backend that runs on the user's machine.
Provides REST API for the desktop Electron application.
Features:
- Local SQLite database
- Full denomination calculation
- History management
- Bulk processing
- Export functionality
- Optional cloud sync when online
"""
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager
import sys
from pathlib import Path
# Add core-engine to path
core_engine_path = Path(__file__).parent.parent / "core-engine"
sys.path.insert(0, str(core_engine_path))
from app.api import calculations, history, export, settings, translations
from app.database import engine, init_db
from app.config import settings as app_settings
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan manager."""
# Startup
print("?? Starting Local Backend API...")
print(f"?? Database: {app_settings.LOCAL_DB_PATH}")
await init_db()
print("✓ Database initialized")
yield
# Shutdown
print("👋 Shutting down Local Backend API...")
# Create FastAPI app
app = FastAPI(
title="Currency Denomination System - Local API",
description="Offline-first backend for desktop application",
version="1.0.0",
lifespan=lifespan
)
# CORS middleware (allow Electron app to connect)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify Electron app origin
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Root endpoint
@app.get("/")
async def root():
"""API root - health check."""
return {
"service": "Currency Denomination System - Local API",
"version": "1.0.0",
"mode": "offline",
"status": "operational",
"database": str(app_settings.LOCAL_DB_PATH),
"sync_enabled": app_settings.SYNC_ENABLED,
"endpoints": {
"calculations": "/api/v1/calculate",
"bulk": "/api/v1/bulk-calculate",
"history": "/api/v1/history",
"export": "/api/v1/export",
"settings": "/api/v1/settings",
"translations": "/api/v1/translations",
"docs": "/docs"
}
}
# Health check
@app.get("/health")
async def health_check():
"""Health check endpoint."""
return {
"status": "healthy",
"database": "connected"
}
# Include routers
app.include_router(
calculations.router,
prefix="/api/v1",
tags=["calculations"]
)
app.include_router(
history.router,
prefix="/api/v1",
tags=["history"]
)
app.include_router(
export.router,
prefix="/api/v1",
tags=["export"]
)
app.include_router(
settings.router,
prefix="/api/v1",
tags=["settings"]
)
app.include_router(
translations.router,
prefix="/api/v1",
tags=["translations"]
)
# Global exception handler
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
"""Handle unexpected errors gracefully."""
return JSONResponse(
status_code=500,
content={
"error": "Internal server error",
"detail": str(exc) if app_settings.DEBUG else "An error occurred"
}
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="127.0.0.1",
port=8001,
reload=True,
log_level="info"
)
data/
?? packages\local-backend\data\local.db
plaintext
SQLite format 3 @ � � � .�
�
�
�
��
[')
� f5)}indexix_export_records_idexport_recordsCREATE INDEX ix_export_records_id ON export_records (id)�{))�1tableexport_recordsexport_recordsCREATE TABLE export_records (
id INTEGER NOT NULL,
export_type VARCHAR(20) NOT NULL,
file_path VARCHAR NOT NULL,
item_count INTEGER,
file_size_bytes INTEGER,
created_at DATETIME,
PRIMARY KEY (id)
)b3'yindexix_user_settings_iduser_settingsCREATE INDEX ix_user_settings_id ON user_settings (id)�M''�Ytableuser_settingsuser_settingsCREATE TABLE user_settings (
id INTEGER NOT NULL,
"key" VARCHAR(100) NOT NULL,
value TEXT NOT NULL,
updated_at DATETIME,
PRIMARY KEY (id),
UNIQUE ("key")
)9M' indexsqlite_autoindex_user_settings_1user_settings ^1%uindexix_calculations_idcalculationsCREATE INDEX ix_calculations_id ON calculations (id)�%%�AtablecalculationscalculationsCREATE TABLE calculations (
id INTEGER NOT NULL,
amount VARCHAR NOT NULL,
currency VARCHAR(3) NOT NULL,
source_currency VARCHAR(3),
target_currency VARCHAR(3),
exchange_rate VARCHAR,
optimization_mode VARCHAR(50),
constraints TEXT,
result TEXT NOT NULL,
total_notes INTEGER,
total_coins INTEGER,
total_denominations INTEGER,
source VARCHAR(20),
synced BOOLEAN,
cloud_id VARCHAR,
created_at DATETIME,
updated_at DATETIME,
PRIMARY KEY (id)
) �I ��������������������������{uoic]WQKE?93-'! ���������������������ysmga[UOIC=71+%
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
�
}
w
q
k
e
_
Y
S
M
G
A
;
5
/
)
#
����������������������{uoic]WQKE?93-'! ���������������������ysmga[UOIt": 1, "total_value": "2", "is_note": false}], "total_notes": 53, "total_coins": 1, "total_denominations": 54, "optimization_mode": "greedy", "constraints_applied": [], "source_currency": null, "exchange_rate": null, "converted_amount": null, "explanation": null, "metadata": {}}56bulk_upload2025-11-23 18:14:33.7591812025-11-23 18:14:33.759189�8 �9 # AA25452INRgreedy{"original_amount": "25452", "currency": "INR", "breakdowns": [{"denomination": "500", "count": 50, "total_value": "25000", "is_note": true}, {"denomination": "200", "count": 2, "total_value": "400", "is_note": true}, {"denomination": "50", "count": 1, "total_value": "50", "is_note": true}, {"denomination": "2", "count": 1, "total_value": "2", "is_note": false}], "total_notes": 53, "total_coins": 1, "total_denominations": 54, "optimization_mode": "greedy", "constraints_applied": [], "source_currency": null, "exchange_rate": null, "converted_amount": null, "explanation": null, "metadata": {}}56bulk_upload2025-11-23 18:14:22.7915942025-11-23 18:14:22.791654�z �; AA5262514INRgreedy{"original_amount": "5262514", "currency": "INR", "breakdowns": [{"denomination": "500", "count": 10525, "total_value": "5262500", "is_note": true}, {"denomination": "10", "count": 1, "total_value": "10", "is_note": true}, {"denomination": "2", "count": 2, "total_value": "4", "is_note": false}], "total_notes": 10526, "total_coins": 2, "total_denominations": 10528, "optimization_mode": "greedy", "constraints_applied": [], "source_currency": null, "exchange_rate": null, "converted_amount": null, "explanation": null, "metadata": {}})) desktop2025-11-23 17:50:47.5650082025-11-23 17:50:47.565023�_ � AA252INRgreedy{"original_amount": "252", "currency": "INR", "breakdowns": [{"denomination": "200", "count": 1, "total_value": "200", "is_note": true}, {"denomination": "50", "count": 1, "total_value": "50", "is_note": true}, {"denomination": "2", "count": 1, "total_value": "2", "is_note": false}], "total_notes": 2, "total_coins": 1, "total_denominations": 3, "optimization_mode": "greedy", "constraints_applied": [], "source_currency": null, "exchange_rate": null, "converted_amount": null, "explanation": null, "metadata": {}}desktop2025-11-23 17:48:15.9073842025-11-23 17:48:15.907446�1 �5 AA6451EURgreedy{"original_amount": "6451", "currency": "EUR", "breakdowns": [{"denomination": "500", "count": 12, "total_value": "6000", "is_note": true}, {"