Initial build: SimpleScan Solo v0.1.0 - Freelancer receipt capture Chrome extension
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.venv/
|
||||||
|
.verdict
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
// Test categories
|
||||||
|
import { TAX_CATEGORIES, getCategoryLabel, getCategoryIcon } from '../utils/categories.js';
|
||||||
|
describe('categories', () => {
|
||||||
|
it('has 10 tax categories', () => {
|
||||||
|
assert.strictEqual(TAX_CATEGORIES.length, 10);
|
||||||
|
});
|
||||||
|
it('gets correct label', () => {
|
||||||
|
assert.strictEqual(getCategoryLabel('software'), 'Software');
|
||||||
|
assert.strictEqual(getCategoryLabel('equipment'), 'Equipment');
|
||||||
|
assert.strictEqual(getCategoryLabel('nonexistent'), 'Other');
|
||||||
|
});
|
||||||
|
it('gets correct icon', () => {
|
||||||
|
assert.strictEqual(getCategoryIcon('software'), '⚙️');
|
||||||
|
assert.strictEqual(getCategoryIcon('travel'), '✈️');
|
||||||
|
assert.strictEqual(getCategoryIcon('nonexistent'), '📦');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Test export
|
||||||
|
import { exportToCsv } from '../utils/export.js';
|
||||||
|
describe('export', () => {
|
||||||
|
it('exports receipts to CSV', () => {
|
||||||
|
const receipts = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Adobe CC',
|
||||||
|
amount: 59.99,
|
||||||
|
currency: 'USD',
|
||||||
|
category: 'software',
|
||||||
|
date: '2026-06-01',
|
||||||
|
vendor: 'Adobe',
|
||||||
|
notes: 'Monthly',
|
||||||
|
source: 'manual',
|
||||||
|
createdAt: '2026-06-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const csv = exportToCsv(receipts);
|
||||||
|
console.log('CSV output:', csv);
|
||||||
|
assert.ok(csv.includes('Date'), 'CSV should have Date header');
|
||||||
|
assert.ok(csv.includes('Adobe CC'), 'CSV should have title');
|
||||||
|
assert.ok(csv.includes('59.99'), 'CSV should have amount');
|
||||||
|
assert.ok(csv.includes('Software'), 'CSV should have category label');
|
||||||
|
});
|
||||||
|
it('escapes quotes in CSV', () => {
|
||||||
|
const receipts = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'My "Special" Item',
|
||||||
|
amount: 10,
|
||||||
|
currency: 'USD',
|
||||||
|
category: 'other',
|
||||||
|
date: '2026-06-01',
|
||||||
|
vendor: 'Vendor',
|
||||||
|
notes: '',
|
||||||
|
source: 'manual',
|
||||||
|
createdAt: '2026-06-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const csv = exportToCsv(receipts);
|
||||||
|
assert.ok(csv.includes('""'), 'CSV should escape quotes');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const DEFAULT_SETTINGS = {
|
||||||
|
defaultCurrency: 'USD',
|
||||||
|
taxYear: new Date().getFullYear().toString(),
|
||||||
|
ownerName: '',
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export const TAX_CATEGORIES = [
|
||||||
|
{
|
||||||
|
id: 'equipment',
|
||||||
|
label: 'Equipment',
|
||||||
|
description: 'Computers, cameras, tools, hardware',
|
||||||
|
icon: '💻',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'home_office',
|
||||||
|
label: 'Home Office',
|
||||||
|
description: 'Desk, chair, lighting, office supplies',
|
||||||
|
icon: '🏠',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'software',
|
||||||
|
label: 'Software',
|
||||||
|
description: 'Apps, subscriptions, SaaS tools',
|
||||||
|
icon: '⚙️',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'internet_phone',
|
||||||
|
label: 'Internet & Phone',
|
||||||
|
description: 'ISP, cell plan, domain hosting',
|
||||||
|
icon: '📡',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'travel',
|
||||||
|
label: 'Travel',
|
||||||
|
description: 'Flights, hotels, rideshare, mileage',
|
||||||
|
icon: '✈️',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'meals',
|
||||||
|
label: 'Meals',
|
||||||
|
description: 'Business meals (50% deductible)',
|
||||||
|
icon: '🍽️',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'professional_services',
|
||||||
|
label: 'Professional Services',
|
||||||
|
description: 'Lawyer, accountant, contractor payments',
|
||||||
|
icon: '📋',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'marketing',
|
||||||
|
label: 'Marketing',
|
||||||
|
description: 'Ads, design, copywriting, PR',
|
||||||
|
icon: '📢',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'education',
|
||||||
|
label: 'Education',
|
||||||
|
description: 'Courses, books, conferences',
|
||||||
|
icon: '📚',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'other',
|
||||||
|
label: 'Other',
|
||||||
|
description: 'Miscellaneous business expenses',
|
||||||
|
icon: '📦',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export function getCategoryLabel(id) {
|
||||||
|
return TAX_CATEGORIES.find((c) => c.id === id)?.label || 'Other';
|
||||||
|
}
|
||||||
|
export function getCategoryIcon(id) {
|
||||||
|
return TAX_CATEGORIES.find((c) => c.id === id)?.icon || '📦';
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { getCategoryLabel } from './categories.js';
|
||||||
|
export function exportToCsv(receipts) {
|
||||||
|
const headers = ['Date', 'Vendor', 'Title', 'Category', 'Amount', 'Currency', 'Notes', 'Source'];
|
||||||
|
const rows = receipts.map((r) => [
|
||||||
|
r.date,
|
||||||
|
r.vendor,
|
||||||
|
r.title,
|
||||||
|
getCategoryLabel(r.category),
|
||||||
|
r.amount.toFixed(2),
|
||||||
|
r.currency,
|
||||||
|
r.notes,
|
||||||
|
r.source,
|
||||||
|
]);
|
||||||
|
const csv = [headers, ...rows]
|
||||||
|
.map((row) => row
|
||||||
|
.map((cell) => {
|
||||||
|
const str = String(cell).replace(/"/g, '""');
|
||||||
|
return `"${str}"`;
|
||||||
|
})
|
||||||
|
.join(','))
|
||||||
|
.join('\n');
|
||||||
|
return csv;
|
||||||
|
}
|
||||||
|
export function downloadBlob(content, filename, mimeType) {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { DEFAULT_SETTINGS } from '../types.js';
|
||||||
|
const RECEIPTS_KEY = 'simplescan_receipts';
|
||||||
|
const SETTINGS_KEY = 'simplescan_settings';
|
||||||
|
export async function getReceipts() {
|
||||||
|
const result = await chrome.storage.local.get(RECEIPTS_KEY);
|
||||||
|
return result[RECEIPTS_KEY] || [];
|
||||||
|
}
|
||||||
|
export async function saveReceipt(receipt) {
|
||||||
|
const receipts = await getReceipts();
|
||||||
|
const existingIndex = receipts.findIndex((r) => r.id === receipt.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
receipts[existingIndex] = receipt;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
receipts.unshift(receipt);
|
||||||
|
}
|
||||||
|
await chrome.storage.local.set({ [RECEIPTS_KEY]: receipts });
|
||||||
|
}
|
||||||
|
export async function deleteReceipt(id) {
|
||||||
|
const receipts = await getReceipts();
|
||||||
|
const filtered = receipts.filter((r) => r.id !== id);
|
||||||
|
await chrome.storage.local.set({ [RECEIPTS_KEY]: filtered });
|
||||||
|
}
|
||||||
|
export async function getSettings() {
|
||||||
|
const result = await chrome.storage.local.get(SETTINGS_KEY);
|
||||||
|
return result[SETTINGS_KEY] || { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
export async function saveSettings(settings) {
|
||||||
|
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
|
||||||
|
}
|
||||||
|
export async function clearAllReceipts() {
|
||||||
|
await chrome.storage.local.remove(RECEIPTS_KEY);
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SimpleScan Solo - Settings & Export</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1a1a2e;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 24px;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
h1 { font-size: 24px; margin-bottom: 4px; }
|
||||||
|
.subtitle { color: #6c757d; margin-bottom: 20px; }
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.card h2 { font-size: 16px; margin-bottom: 14px; color: #16213e; }
|
||||||
|
.form-group { margin-bottom: 14px; }
|
||||||
|
label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 5px; color: #343a40; }
|
||||||
|
input, select {
|
||||||
|
width: 100%; max-width: 300px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f3460;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f3460;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #16213e; }
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #343a40;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: #f8f9fa; }
|
||||||
|
.btn-danger {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: 1px solid #dc3545;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-danger:hover { background: #fff5f5; }
|
||||||
|
.export-grid { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
|
||||||
|
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e9ecef; }
|
||||||
|
.summary-row:last-child { border-bottom: none; }
|
||||||
|
.summary-label { color: #6c757d; }
|
||||||
|
.summary-value { font-weight: 600; }
|
||||||
|
.receipts-table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 13px; }
|
||||||
|
.receipts-table th { text-align: left; padding: 8px; border-bottom: 2px solid #e9ecef; color: #6c757d; font-weight: 600; }
|
||||||
|
.receipts-table td { padding: 8px; border-bottom: 1px solid #e9ecef; }
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%);
|
||||||
|
background: #16213e; color: #fff; padding: 10px 20px; border-radius: 8px;
|
||||||
|
font-weight: 500; opacity: 1; transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
.toast.hidden { opacity: 0; pointer-events: none; }
|
||||||
|
.danger-zone { border: 1px solid #f5c6cb; background: #fff5f5; }
|
||||||
|
.danger-zone h2 { color: #721c24; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>📸 SimpleScan Solo</h1>
|
||||||
|
<p class="subtitle">Settings & Export Center</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>⚙️ Your Settings</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-name">Your Name (for exports)</label>
|
||||||
|
<input type="text" id="s-name" placeholder="Jane Doe" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-currency">Default Currency</label>
|
||||||
|
<select id="s-currency">
|
||||||
|
<option value="USD">USD ($)</option>
|
||||||
|
<option value="EUR">EUR (€)</option>
|
||||||
|
<option value="GBP">GBP (£)</option>
|
||||||
|
<option value="CAD">CAD (C$)</option>
|
||||||
|
<option value="AUD">AUD (A$)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s-year">Tax Year</label>
|
||||||
|
<input type="text" id="s-year" placeholder="2026" maxlength="4" />
|
||||||
|
</div>
|
||||||
|
<button id="save-settings" class="btn-primary">Save Settings</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>📊 Receipt Summary</h2>
|
||||||
|
<div id="summary-content">
|
||||||
|
<div class="summary-row"><span class="summary-label">Total Receipts</span><span class="summary-value" id="sum-count">0</span></div>
|
||||||
|
<div class="summary-row"><span class="summary-label">Total Amount</span><span class="summary-value" id="sum-total">$0.00</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>📄 Export Receipts</h2>
|
||||||
|
<div class="export-grid">
|
||||||
|
<button id="export-csv" class="btn-secondary">Download CSV</button>
|
||||||
|
<button id="export-pdf" class="btn-secondary">Download PDF Report</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>📋 All Receipts</h2>
|
||||||
|
<div id="receipts-table-wrap">
|
||||||
|
<p style="color:#6c757d; padding: 16px 0;">No receipts yet. Add some from the extension popup.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card danger-zone">
|
||||||
|
<h2>⚠️ Danger Zone</h2>
|
||||||
|
<p style="color:#721c24; font-size:13px; margin-bottom:12px;">This will permanently delete all your saved receipts. This cannot be undone.</p>
|
||||||
|
<button id="clear-all" class="btn-danger">Delete All Receipts</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
|
||||||
|
<script type="module" src="./src/options.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1848
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "simplescan-solo",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "tsc -p tsconfig.test.json && node --test dist-test/**/*.test.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"jspdf": "^2.5.1",
|
||||||
|
"jspdf-autotable": "^3.8.2",
|
||||||
|
"tesseract.js": "^5.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@crxjs/vite-plugin": "^2.0.0-beta.26",
|
||||||
|
"@types/chrome": "^0.0.268",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.2.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 307 B |
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
Binary file not shown.
|
After Width: | Height: | Size: 124 B |
Binary file not shown.
|
After Width: | Height: | Size: 307 B |
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
Binary file not shown.
|
After Width: | Height: | Size: 124 B |
@@ -0,0 +1,65 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
const iconsDir = path.join(__dirname, 'icons');
|
||||||
|
fs.mkdirSync(iconsDir, { recursive: true });
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
const table = [];
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
table[i] = c;
|
||||||
|
}
|
||||||
|
let crc = -1;
|
||||||
|
for (const byte of buf) {
|
||||||
|
crc = table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (crc ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChunk(typeStr, data) {
|
||||||
|
const typeBuf = Buffer.from(typeStr, 'ascii');
|
||||||
|
const c = crc32(Buffer.concat([typeBuf, data]));
|
||||||
|
const crcBuf = Buffer.alloc(4);
|
||||||
|
crcBuf.writeUInt32BE(c, 0);
|
||||||
|
const lenBuf = Buffer.alloc(4);
|
||||||
|
lenBuf.writeUInt32BE(data.length, 0);
|
||||||
|
return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePng(size, r, g, b) {
|
||||||
|
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
const ihdrData = Buffer.alloc(13);
|
||||||
|
ihdrData.writeUInt32BE(size, 0);
|
||||||
|
ihdrData.writeUInt32BE(size, 4);
|
||||||
|
ihdrData.writeUInt8(8, 8);
|
||||||
|
ihdrData.writeUInt8(2, 9);
|
||||||
|
ihdrData.writeUInt8(0, 10);
|
||||||
|
ihdrData.writeUInt8(0, 11);
|
||||||
|
ihdrData.writeUInt8(0, 12);
|
||||||
|
|
||||||
|
const rawData = [];
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
rawData.push(0);
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
rawData.push(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const imageData = zlib.deflateSync(Buffer.from(rawData));
|
||||||
|
|
||||||
|
const ihdr = makeChunk('IHDR', ihdrData);
|
||||||
|
const idat = makeChunk('IDAT', imageData);
|
||||||
|
const iend = makeChunk('IEND', Buffer.alloc(0));
|
||||||
|
|
||||||
|
return Buffer.concat([sig, ihdr, idat, iend]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = 15, g = 52, b = 96;
|
||||||
|
fs.writeFileSync(path.join(iconsDir, 'icon16.png'), makePng(16, r, g, b));
|
||||||
|
fs.writeFileSync(path.join(iconsDir, 'icon48.png'), makePng(48, r, g, b));
|
||||||
|
fs.writeFileSync(path.join(iconsDir, 'icon128.png'), makePng(128, r, g, b));
|
||||||
|
console.log('Icons created');
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
const iconsDir = path.join(__dirname, '..', 'public', 'icons');
|
||||||
|
fs.mkdirSync(iconsDir, { recursive: true });
|
||||||
|
|
||||||
|
function crc32(buf) {
|
||||||
|
const table = [];
|
||||||
|
for (let i = 0; i < 256; i++) {
|
||||||
|
let c = i;
|
||||||
|
for (let k = 0; k < 8; k++) {
|
||||||
|
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
||||||
|
}
|
||||||
|
table[i] = c;
|
||||||
|
}
|
||||||
|
let crc = -1;
|
||||||
|
for (const byte of buf) {
|
||||||
|
crc = table[(crc ^ byte) & 0xFF] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (crc ^ -1) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeChunk(typeStr, data) {
|
||||||
|
const typeBuf = Buffer.from(typeStr, 'ascii');
|
||||||
|
const c = crc32(Buffer.concat([typeBuf, data]));
|
||||||
|
const crcBuf = Buffer.alloc(4);
|
||||||
|
crcBuf.writeUInt32BE(c, 0);
|
||||||
|
const lenBuf = Buffer.alloc(4);
|
||||||
|
lenBuf.writeUInt32BE(data.length, 0);
|
||||||
|
return Buffer.concat([lenBuf, typeBuf, data, crcBuf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePng(size, r, g, b) {
|
||||||
|
const sig = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
const ihdrData = Buffer.alloc(13);
|
||||||
|
ihdrData.writeUInt32BE(size, 0);
|
||||||
|
ihdrData.writeUInt32BE(size, 4);
|
||||||
|
ihdrData.writeUInt8(8, 8);
|
||||||
|
ihdrData.writeUInt8(2, 9);
|
||||||
|
ihdrData.writeUInt8(0, 10);
|
||||||
|
ihdrData.writeUInt8(0, 11);
|
||||||
|
ihdrData.writeUInt8(0, 12);
|
||||||
|
|
||||||
|
const rawData = [];
|
||||||
|
for (let y = 0; y < size; y++) {
|
||||||
|
rawData.push(0);
|
||||||
|
for (let x = 0; x < size; x++) {
|
||||||
|
rawData.push(r, g, b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const imageData = zlib.deflateSync(Buffer.from(rawData));
|
||||||
|
|
||||||
|
const ihdr = makeChunk('IHDR', ihdrData);
|
||||||
|
const idat = makeChunk('IDAT', imageData);
|
||||||
|
const iend = makeChunk('IEND', Buffer.alloc(0));
|
||||||
|
|
||||||
|
return Buffer.concat([sig, ihdr, idat, iend]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = 15, g = 52, b = 96;
|
||||||
|
fs.writeFileSync(path.join(iconsDir, 'icon16.png'), makePng(16, r, g, b));
|
||||||
|
fs.writeFileSync(path.join(iconsDir, 'icon48.png'), makePng(48, r, g, b));
|
||||||
|
fs.writeFileSync(path.join(iconsDir, 'icon128.png'), makePng(128, r, g, b));
|
||||||
|
console.log('Icons created in public/icons');
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const secretPatterns = [
|
||||||
|
/sk-[a-zA-Z0-9]{20,}/g,
|
||||||
|
/eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*/g,
|
||||||
|
/api[_-]?key\s*[:=]\s*['"][a-zA-Z0-9]{16,}['"]/gi,
|
||||||
|
/token\s*[:=]\s*['"][a-zA-Z0-9]{16,}['"]/gi,
|
||||||
|
];
|
||||||
|
|
||||||
|
const filesToCheck = [
|
||||||
|
'src/types.ts', 'src/background.ts', 'src/options.ts',
|
||||||
|
'src/popup/popup.ts', 'src/popup/popup.html', 'src/popup/popup.css',
|
||||||
|
'src/content/email-scanner.ts',
|
||||||
|
'src/utils/storage.ts', 'src/utils/categories.ts', 'src/utils/export.ts',
|
||||||
|
'options.html', 'package.json', 'vite.config.ts',
|
||||||
|
'tsconfig.json', 'tsconfig.test.json', '.gitignore'
|
||||||
|
];
|
||||||
|
|
||||||
|
let found = false;
|
||||||
|
for (const file of filesToCheck) {
|
||||||
|
const fp = path.join(__dirname, '..', file);
|
||||||
|
if (!fs.existsSync(fp)) continue;
|
||||||
|
const content = fs.readFileSync(fp, 'utf8');
|
||||||
|
for (const pattern of secretPatterns) {
|
||||||
|
const matches = content.match(pattern);
|
||||||
|
if (matches) {
|
||||||
|
console.log(`SECRET FOUND in ${file}: ${matches[0].substring(0, 30)}...`);
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
console.log('SECRETS DETECTED - aborting');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('No secrets found');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Background service worker for SimpleScan Solo
|
||||||
|
|
||||||
|
chrome.runtime.onInstalled.addListener((details) => {
|
||||||
|
if (details.reason === 'install') {
|
||||||
|
// Set default settings on install
|
||||||
|
chrome.storage.local.set({
|
||||||
|
simplescan_settings: {
|
||||||
|
defaultCurrency: 'USD',
|
||||||
|
taxYear: new Date().getFullYear().toString(),
|
||||||
|
ownerName: '',
|
||||||
|
},
|
||||||
|
simplescan_receipts: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep alive for messaging
|
||||||
|
chrome.runtime.onMessage.addListener((_request, _sender, sendResponse) => {
|
||||||
|
sendResponse({ ok: true });
|
||||||
|
return true;
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Content script: scan Gmail / Outlook web for receipt emails
|
||||||
|
|
||||||
|
interface EmailHint {
|
||||||
|
subject: string;
|
||||||
|
sender: string;
|
||||||
|
date: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
vendor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectHost(): 'gmail' | 'outlook' | null {
|
||||||
|
const host = location.hostname;
|
||||||
|
if (host.includes('mail.google.com')) return 'gmail';
|
||||||
|
if (host.includes('outlook.')) return 'outlook';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAmount(text: string): { amount?: number; currency?: string } {
|
||||||
|
// Match patterns like $29.99, USD 29.99, 29.99 USD, €19.50
|
||||||
|
const patterns = [
|
||||||
|
/\$\s?([\d,]+\.?\d{0,2})/,
|
||||||
|
/€\s?([\d,]+\.?\d{0,2})/,
|
||||||
|
/£\s?([\d,]+\.?\d{0,2})/,
|
||||||
|
/([\d,]+\.?\d{0,2})\s?USD/i,
|
||||||
|
/([\d,]+\.?\d{0,2})\s?EUR/i,
|
||||||
|
/total[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
|
||||||
|
/amount[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
|
||||||
|
/charged[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
|
||||||
|
/payment[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
|
||||||
|
/price[\s:]*\$?\s?([\d,]+\.?\d{0,2})/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = text.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
const amount = parseFloat(match[1].replace(/,/g, ''));
|
||||||
|
const currency = text.includes('€')
|
||||||
|
? 'EUR'
|
||||||
|
: text.includes('£')
|
||||||
|
? 'GBP'
|
||||||
|
: text.includes('EUR')
|
||||||
|
? 'EUR'
|
||||||
|
: 'USD';
|
||||||
|
if (!isNaN(amount) && amount > 0) {
|
||||||
|
return { amount, currency };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isReceiptEmail(subject: string, body: string): boolean {
|
||||||
|
const receiptKeywords = [
|
||||||
|
'receipt', 'invoice', 'payment confirmation', 'order confirmation',
|
||||||
|
'your receipt', 'purchase confirmation', 'billing receipt',
|
||||||
|
'subscription receipt', 'payment receipt', 'order summary',
|
||||||
|
'thank you for your purchase', 'payment successful',
|
||||||
|
'charge confirmation', 'transaction confirmation',
|
||||||
|
];
|
||||||
|
const lowerSub = subject.toLowerCase();
|
||||||
|
const lowerBody = body.toLowerCase().slice(0, 2000);
|
||||||
|
return receiptKeywords.some((k) => lowerSub.includes(k) || lowerBody.includes(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanGmail(): EmailHint[] {
|
||||||
|
const hints: EmailHint[] = [];
|
||||||
|
// Try to find email rows in conversation view or list view
|
||||||
|
const subjects = document.querySelectorAll('h2[data-thread-perm-id]');
|
||||||
|
if (subjects.length > 0) {
|
||||||
|
// Conversation view - single email thread
|
||||||
|
for (const subjEl of subjects) {
|
||||||
|
const subject = subjEl.textContent?.trim() || '';
|
||||||
|
const senderEl = document.querySelector('span[email]');
|
||||||
|
const sender = senderEl?.getAttribute('email') || senderEl?.textContent?.trim() || '';
|
||||||
|
const dateEl = document.querySelector('td[colspan] span[title]');
|
||||||
|
const date = dateEl?.getAttribute('title') || dateEl?.textContent?.trim() || '';
|
||||||
|
const bodyEl = document.querySelector('.ii.gt');
|
||||||
|
const body = bodyEl?.textContent?.trim() || '';
|
||||||
|
if (isReceiptEmail(subject, body)) {
|
||||||
|
const { amount, currency } = parseAmount(subject + ' ' + body);
|
||||||
|
hints.push({ subject, sender, date, amount, currency, vendor: sender.split('@')[1] || sender });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// List view - multiple emails
|
||||||
|
const rows = document.querySelectorAll('tr.zA');
|
||||||
|
for (const row of rows) {
|
||||||
|
const subjectEl = row.querySelector('.y6');
|
||||||
|
const subject = subjectEl?.textContent?.trim() || '';
|
||||||
|
const senderEl = row.querySelector('.yW span[email]');
|
||||||
|
const sender = senderEl?.getAttribute('email') || senderEl?.textContent?.trim() || '';
|
||||||
|
const dateEl = row.querySelector('.xY span');
|
||||||
|
const date = dateEl?.textContent?.trim() || '';
|
||||||
|
const snippetEl = row.querySelector('.y2');
|
||||||
|
const snippet = snippetEl?.textContent?.trim() || '';
|
||||||
|
if (isReceiptEmail(subject, snippet)) {
|
||||||
|
const { amount, currency } = parseAmount(subject + ' ' + snippet);
|
||||||
|
hints.push({ subject, sender, date, amount, currency, vendor: sender.split('@')[1] || sender });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanOutlook(): EmailHint[] {
|
||||||
|
const hints: EmailHint[] = [];
|
||||||
|
// Outlook web - reading pane or list view
|
||||||
|
const readingPaneSubject = document.querySelector('[role="region"] div[title]');
|
||||||
|
if (readingPaneSubject) {
|
||||||
|
// Reading pane mode
|
||||||
|
const subject = readingPaneSubject.getAttribute('title')?.trim() || '';
|
||||||
|
const senderEl = document.querySelector('[role="region"] span[data-lpc-disable-compression]');
|
||||||
|
const sender = senderEl?.textContent?.trim() || '';
|
||||||
|
const dateEl = document.querySelector('[role="region"] [data-testid="messageHeaderDate"]');
|
||||||
|
const date = dateEl?.textContent?.trim() || '';
|
||||||
|
const bodyEl = document.querySelector('[role="region"] #UniqueMessageBody, [role="region"] .x_text-container');
|
||||||
|
const body = bodyEl?.textContent?.trim() || '';
|
||||||
|
if (isReceiptEmail(subject, body)) {
|
||||||
|
const { amount, currency } = parseAmount(subject + ' ' + body);
|
||||||
|
hints.push({ subject, sender, date, amount, currency, vendor: sender.split('@')[1] || sender });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// List view
|
||||||
|
const items = document.querySelectorAll('[data-testid="mailListItem"], .lvHighlightAllClass');
|
||||||
|
for (const item of items) {
|
||||||
|
const subjectEl = item.querySelector('[data-testid="threadItemSubject"]');
|
||||||
|
const subject = subjectEl?.textContent?.trim() || '';
|
||||||
|
const senderEl = item.querySelector('[data-testid="threadItemFrom"]');
|
||||||
|
const sender = senderEl?.textContent?.trim() || '';
|
||||||
|
const dateEl = item.querySelector('[data-testid="threadItemDate"]');
|
||||||
|
const date = dateEl?.textContent?.trim() || '';
|
||||||
|
const snippetEl = item.querySelector('[data-testid="threadItemPreview"]');
|
||||||
|
const snippet = snippetEl?.textContent?.trim() || '';
|
||||||
|
if (isReceiptEmail(subject, snippet)) {
|
||||||
|
const { amount, currency } = parseAmount(subject + ' ' + snippet);
|
||||||
|
hints.push({ subject, sender, date, amount, currency, vendor: sender.split('@')[1] || sender });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from popup
|
||||||
|
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||||
|
if (request.action === 'scanEmails') {
|
||||||
|
const host = detectHost();
|
||||||
|
let receipts: EmailHint[] = [];
|
||||||
|
if (host === 'gmail') {
|
||||||
|
receipts = scanGmail();
|
||||||
|
} else if (host === 'outlook') {
|
||||||
|
receipts = scanOutlook();
|
||||||
|
}
|
||||||
|
sendResponse({ receipts });
|
||||||
|
}
|
||||||
|
return true; // async response
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "SimpleScan Solo - Freelancer Receipts",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Dead-simple receipt capture for freelancers. No team bloat, just capture, categorize, export.",
|
||||||
|
"permissions": ["storage", "activeTab", "scripting"],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://mail.google.com/*",
|
||||||
|
"https://outlook.live.com/*",
|
||||||
|
"https://outlook.office.com/*",
|
||||||
|
"https://outlook.office365.com/*"
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_popup": "src/popup/popup.html",
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"service_worker": "src/background.ts",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://mail.google.com/*",
|
||||||
|
"https://outlook.live.com/*",
|
||||||
|
"https://outlook.office.com/*",
|
||||||
|
"https://outlook.office365.com/*"
|
||||||
|
],
|
||||||
|
"js": ["src/content/email-scanner.ts"],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"options_page": "options.html",
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
import { getReceipts, getSettings, saveSettings, clearAllReceipts } from './utils/storage.js';
|
||||||
|
import { getCategoryLabel } from './utils/categories.js';
|
||||||
|
import { exportToCsv, downloadBlob } from './utils/export.js';
|
||||||
|
import type { Receipt, AppSettings } from './types.js';
|
||||||
|
|
||||||
|
const nameInput = document.getElementById('s-name') as HTMLInputElement;
|
||||||
|
const currencySelect = document.getElementById('s-currency') as HTMLSelectElement;
|
||||||
|
const yearInput = document.getElementById('s-year') as HTMLInputElement;
|
||||||
|
const saveBtn = document.getElementById('save-settings') as HTMLButtonElement;
|
||||||
|
const sumCount = document.getElementById('sum-count') as HTMLSpanElement;
|
||||||
|
const sumTotal = document.getElementById('sum-total') as HTMLSpanElement;
|
||||||
|
const tableWrap = document.getElementById('receipts-table-wrap') as HTMLDivElement;
|
||||||
|
const toast = document.getElementById('toast') as HTMLDivElement;
|
||||||
|
|
||||||
|
let currentReceipts: Receipt[] = [];
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
nameInput.value = settings.ownerName || '';
|
||||||
|
currencySelect.value = settings.defaultCurrency || 'USD';
|
||||||
|
yearInput.value = settings.taxYear || new Date().getFullYear().toString();
|
||||||
|
|
||||||
|
await loadData();
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const settings: AppSettings = {
|
||||||
|
ownerName: nameInput.value.trim(),
|
||||||
|
defaultCurrency: currencySelect.value,
|
||||||
|
taxYear: yearInput.value.trim(),
|
||||||
|
};
|
||||||
|
await saveSettings(settings);
|
||||||
|
showToast('Settings saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('export-csv')!.addEventListener('click', async () => {
|
||||||
|
if (currentReceipts.length === 0) {
|
||||||
|
showToast('No receipts to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const csv = exportToCsv(currentReceipts);
|
||||||
|
const year = yearInput.value || new Date().getFullYear();
|
||||||
|
downloadBlob(csv, `simplescan-receipts-${year}.csv`, 'text/csv');
|
||||||
|
showToast('CSV downloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('export-pdf')!.addEventListener('click', async () => {
|
||||||
|
if (currentReceipts.length === 0) {
|
||||||
|
showToast('No receipts to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exportPdf(currentReceipts);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('clear-all')!.addEventListener('click', async () => {
|
||||||
|
if (!confirm('Are you sure? This will delete ALL receipts forever.')) return;
|
||||||
|
await clearAllReceipts();
|
||||||
|
await loadData();
|
||||||
|
showToast('All receipts deleted');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
currentReceipts = await getReceipts();
|
||||||
|
sumCount.textContent = String(currentReceipts.length);
|
||||||
|
|
||||||
|
const total = currentReceipts.reduce((s, r) => s + r.amount, 0);
|
||||||
|
const currency = currentReceipts[0]?.currency || 'USD';
|
||||||
|
sumTotal.textContent = formatMoney(total, currency);
|
||||||
|
|
||||||
|
if (currentReceipts.length === 0) {
|
||||||
|
tableWrap.innerHTML = '<p style="color:#6c757d; padding: 16px 0;">No receipts yet. Add some from the extension popup.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tableWrap.innerHTML = `
|
||||||
|
<table class="receipts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Vendor</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${currentReceipts
|
||||||
|
.map(
|
||||||
|
(r) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(r.date)}</td>
|
||||||
|
<td>${escapeHtml(r.vendor)}</td>
|
||||||
|
<td>${escapeHtml(r.title)}</td>
|
||||||
|
<td>${getCategoryLabel(r.category)}</td>
|
||||||
|
<td style="font-weight:600;">${formatMoney(r.amount, r.currency)}</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPdf(receipts: Receipt[]) {
|
||||||
|
// Simple PDF generation using browser print to PDF approach
|
||||||
|
const currency = receipts[0]?.currency || 'USD';
|
||||||
|
const total = receipts.reduce((s, r) => s + r.amount, 0);
|
||||||
|
const year = yearInput.value || new Date().getFullYear();
|
||||||
|
const owner = nameInput.value.trim() || 'Freelancer';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Receipt Report ${year}</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; padding: 40px; color: #1a1a2e; }
|
||||||
|
h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
.meta { color: #6c757d; margin-bottom: 20px; }
|
||||||
|
table { width: 100%; border-collapse: collapse; margin-top: 16px; }
|
||||||
|
th { text-align: left; padding: 10px; border-bottom: 2px solid #333; font-size: 12px; text-transform: uppercase; }
|
||||||
|
td { padding: 10px; border-bottom: 1px solid #ddd; font-size: 13px; }
|
||||||
|
.total-row { font-weight: bold; background: #f8f9fa; }
|
||||||
|
.footer { margin-top: 30px; font-size: 11px; color: #6c757d; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Receipt Report — ${year}</h1>
|
||||||
|
<div class="meta">${escapeHtml(owner)} · ${receipts.length} receipts · ${formatMoney(total, currency)} total</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Vendor</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Amount</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${receipts
|
||||||
|
.map(
|
||||||
|
(r) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(r.date)}</td>
|
||||||
|
<td>${escapeHtml(r.vendor)}</td>
|
||||||
|
<td>${escapeHtml(r.title)}</td>
|
||||||
|
<td>${getCategoryLabel(r.category)}</td>
|
||||||
|
<td>${formatMoney(r.amount, r.currency)}</td>
|
||||||
|
<td>${escapeHtml(r.notes)}</td>
|
||||||
|
</tr>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
<tr class="total-row">
|
||||||
|
<td colspan="4" style="text-align:right;">Total</td>
|
||||||
|
<td>${formatMoney(total, currency)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="footer">Generated by SimpleScan Solo</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) {
|
||||||
|
showToast('Popup blocked. Allow popups for PDF export.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printWindow.document.write(html);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.focus();
|
||||||
|
setTimeout(() => {
|
||||||
|
printWindow.print();
|
||||||
|
}, 300);
|
||||||
|
showToast('PDF print dialog opened');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(amount: number, currency: string): string {
|
||||||
|
const map: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', CAD: 'C$', AUD: 'A$' };
|
||||||
|
const symbol = map[currency] || currency + ' ';
|
||||||
|
return symbol + amount.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string) {
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.remove('hidden');
|
||||||
|
setTimeout(() => toast.classList.add('hidden'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #1a1a2e;
|
||||||
|
background: #f8f9fa;
|
||||||
|
width: 380px;
|
||||||
|
min-height: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
background: #fff;
|
||||||
|
color: #0f3460;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panels */
|
||||||
|
.tab-panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343a40;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: #fff;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0f3460;
|
||||||
|
box-shadow: 0 0 0 2px rgba(15,52,96,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #ced4da;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
border-color: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area input[type="file"] {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 120px;
|
||||||
|
margin-top: 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn-primary {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f3460;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #16213e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #343a40;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
color: #343a40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-import {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: #0f3460;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-import:hover {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Receipts list */
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 6px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipts-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 32px 16px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 36px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #212529;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-notes {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #868e96;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #0f3460;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-thumb {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 80px;
|
||||||
|
margin-top: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.receipt-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Email tab */
|
||||||
|
.email-intro {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #e7f3ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-results {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-item {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-subject {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-amount {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #0f3460;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #0f3460;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Export bar */
|
||||||
|
.export-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 12px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #16213e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 100;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SimpleScan Solo</title>
|
||||||
|
<link rel="stylesheet" href="./popup.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<header class="header">
|
||||||
|
<h1>📸 SimpleScan Solo</h1>
|
||||||
|
<p class="subtitle">Freelancer receipts, simplified</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs">
|
||||||
|
<button class="tab-btn active" data-tab="add">Add</button>
|
||||||
|
<button class="tab-btn" data-tab="list">Receipts</button>
|
||||||
|
<button class="tab-btn" data-tab="email">Email</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Add Receipt Tab -->
|
||||||
|
<section id="tab-add" class="tab-panel active">
|
||||||
|
<form id="receipt-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="r-title">Description</label>
|
||||||
|
<input type="text" id="r-title" placeholder="e.g. Adobe Creative Cloud" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="r-amount">Amount</label>
|
||||||
|
<input type="number" id="r-amount" step="0.01" min="0" placeholder="29.99" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="r-currency">Currency</label>
|
||||||
|
<select id="r-currency">
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="GBP">GBP</option>
|
||||||
|
<option value="CAD">CAD</option>
|
||||||
|
<option value="AUD">AUD</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="r-category">Category</label>
|
||||||
|
<select id="r-category" required></select>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="r-date">Date</label>
|
||||||
|
<input type="date" id="r-date" required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="r-vendor">Vendor</label>
|
||||||
|
<input type="text" id="r-vendor" placeholder="Adobe" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="r-notes">Notes</label>
|
||||||
|
<input type="text" id="r-notes" placeholder="Annual subscription" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Receipt Image (optional)</label>
|
||||||
|
<div class="upload-area">
|
||||||
|
<input type="file" id="r-image" accept="image/*" />
|
||||||
|
<div class="upload-hint">📎 Click to upload or drag image</div>
|
||||||
|
<img id="image-preview" class="image-preview hidden" alt="Preview" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary">💾 Save Receipt</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Receipts List Tab -->
|
||||||
|
<section id="tab-list" class="tab-panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" id="search-input" placeholder="🔍 Search receipts..." />
|
||||||
|
</div>
|
||||||
|
<div class="summary" id="summary-box">
|
||||||
|
<span id="receipt-count">0</span> receipts · <span id="total-amount">$0.00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="receipts-list" class="receipts-list"></div>
|
||||||
|
<div class="export-bar">
|
||||||
|
<button id="export-csv" class="btn-secondary">📄 CSV</button>
|
||||||
|
<button id="export-pdf" class="btn-secondary">📑 PDF</button>
|
||||||
|
<button id="open-options" class="btn-ghost">⚙️ Settings</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Email Capture Tab -->
|
||||||
|
<section id="tab-email" class="tab-panel">
|
||||||
|
<div class="email-intro">
|
||||||
|
<p>Open <strong>Gmail</strong> or <strong>Outlook</strong> in a tab, then click <strong>Scan Inbox</strong> to find receipt emails.</p>
|
||||||
|
</div>
|
||||||
|
<button id="scan-email" class="btn-primary">🔍 Scan Inbox for Receipts</button>
|
||||||
|
<div id="email-results" class="email-results"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="toast" class="toast hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="./popup.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { getReceipts, saveReceipt, deleteReceipt } from '../utils/storage.js';
|
||||||
|
import { TAX_CATEGORIES, getCategoryLabel, getCategoryIcon } from '../utils/categories.js';
|
||||||
|
import { exportToCsv, downloadBlob } from '../utils/export.js';
|
||||||
|
import type { Receipt } from '../types.js';
|
||||||
|
|
||||||
|
// --- DOM refs ---
|
||||||
|
const tabButtons = document.querySelectorAll<HTMLButtonElement>('.tab-btn');
|
||||||
|
const panels = document.querySelectorAll<HTMLElement>('.tab-panel');
|
||||||
|
const form = document.getElementById('receipt-form') as HTMLFormElement;
|
||||||
|
const categorySelect = document.getElementById('r-category') as HTMLSelectElement;
|
||||||
|
const imageInput = document.getElementById('r-image') as HTMLInputElement;
|
||||||
|
const imagePreview = document.getElementById('image-preview') as HTMLImageElement;
|
||||||
|
const receiptsList = document.getElementById('receipts-list') as HTMLDivElement;
|
||||||
|
const searchInput = document.getElementById('search-input') as HTMLInputElement;
|
||||||
|
const receiptCount = document.getElementById('receipt-count') as HTMLSpanElement;
|
||||||
|
const totalAmount = document.getElementById('total-amount') as HTMLSpanElement;
|
||||||
|
const scanEmailBtn = document.getElementById('scan-email') as HTMLButtonElement;
|
||||||
|
const emailResults = document.getElementById('email-results') as HTMLDivElement;
|
||||||
|
const toast = document.getElementById('toast') as HTMLDivElement;
|
||||||
|
|
||||||
|
let allReceipts: Receipt[] = [];
|
||||||
|
let imageDataUrl = '';
|
||||||
|
|
||||||
|
// --- Init ---
|
||||||
|
function init() {
|
||||||
|
// Populate categories
|
||||||
|
categorySelect.innerHTML = TAX_CATEGORIES.map(
|
||||||
|
(c) => `<option value="${c.id}">${c.icon} ${c.label}</option>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Default date to today
|
||||||
|
(document.getElementById('r-date') as HTMLInputElement).valueAsDate = new Date();
|
||||||
|
|
||||||
|
loadReceipts();
|
||||||
|
setupTabs();
|
||||||
|
setupForm();
|
||||||
|
setupSearch();
|
||||||
|
setupExport();
|
||||||
|
setupEmailScan();
|
||||||
|
setupImageUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tabs ---
|
||||||
|
function setupTabs() {
|
||||||
|
tabButtons.forEach((tab) => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
const target = tab.dataset.tab!;
|
||||||
|
tabButtons.forEach((t) => t.classList.toggle('active', t.dataset.tab === target));
|
||||||
|
panels.forEach((p) => p.classList.toggle('active', p.id === `tab-${target}`));
|
||||||
|
if (target === 'list') loadReceipts();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Form ---
|
||||||
|
function setupForm() {
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const receipt: Receipt = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: (document.getElementById('r-title') as HTMLInputElement).value.trim(),
|
||||||
|
amount: parseFloat((document.getElementById('r-amount') as HTMLInputElement).value),
|
||||||
|
currency: (document.getElementById('r-currency') as HTMLSelectElement).value,
|
||||||
|
category: (document.getElementById('r-category') as HTMLSelectElement).value as Receipt['category'],
|
||||||
|
date: (document.getElementById('r-date') as HTMLInputElement).value,
|
||||||
|
vendor: (document.getElementById('r-vendor') as HTMLInputElement).value.trim(),
|
||||||
|
notes: (document.getElementById('r-notes') as HTMLInputElement).value.trim(),
|
||||||
|
imageDataUrl: imageDataUrl || undefined,
|
||||||
|
source: imageDataUrl ? 'upload' : 'manual',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveReceipt(receipt);
|
||||||
|
showToast('Receipt saved!');
|
||||||
|
form.reset();
|
||||||
|
imageDataUrl = '';
|
||||||
|
imagePreview.classList.add('hidden');
|
||||||
|
imagePreview.src = '';
|
||||||
|
(document.getElementById('r-date') as HTMLInputElement).valueAsDate = new Date();
|
||||||
|
// Switch to list tab
|
||||||
|
tabButtons[1].click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image upload ---
|
||||||
|
function setupImageUpload() {
|
||||||
|
imageInput.addEventListener('change', () => {
|
||||||
|
const file = imageInput.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
imageDataUrl = reader.result as string;
|
||||||
|
imagePreview.src = imageDataUrl;
|
||||||
|
imagePreview.classList.remove('hidden');
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Receipts list ---
|
||||||
|
async function loadReceipts() {
|
||||||
|
allReceipts = await getReceipts();
|
||||||
|
renderReceipts(allReceipts);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReceipts(receipts: Receipt[]) {
|
||||||
|
if (receipts.length === 0) {
|
||||||
|
receiptsList.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📭</div>
|
||||||
|
<p>No receipts yet.</p>
|
||||||
|
<p class="empty-hint">Add your first receipt in the "Add" tab.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
receiptCount.textContent = '0';
|
||||||
|
totalAmount.textContent = '$0.00';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = receipts.reduce((sum, r) => sum + r.amount, 0);
|
||||||
|
const currency = receipts[0]?.currency || 'USD';
|
||||||
|
receiptCount.textContent = String(receipts.length);
|
||||||
|
totalAmount.textContent = formatMoney(total, currency);
|
||||||
|
|
||||||
|
receiptsList.innerHTML = receipts
|
||||||
|
.map(
|
||||||
|
(r) => `
|
||||||
|
<div class="receipt-card" data-id="${r.id}">
|
||||||
|
<div class="receipt-main">
|
||||||
|
<div class="receipt-icon">${getCategoryIcon(r.category)}</div>
|
||||||
|
<div class="receipt-info">
|
||||||
|
<div class="receipt-title">${escapeHtml(r.title)}</div>
|
||||||
|
<div class="receipt-meta">
|
||||||
|
${formatDate(r.date)} · ${escapeHtml(r.vendor)} · ${getCategoryLabel(r.category)}
|
||||||
|
</div>
|
||||||
|
${r.notes ? `<div class="receipt-notes">${escapeHtml(r.notes)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="receipt-amount">${formatMoney(r.amount, r.currency)}</div>
|
||||||
|
</div>
|
||||||
|
${r.imageDataUrl ? `<img class="receipt-thumb" src="${r.imageDataUrl}" alt="Receipt" />` : ''}
|
||||||
|
<div class="receipt-actions">
|
||||||
|
<button class="btn-delete" data-id="${r.id}" title="Delete">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
receiptsList.querySelectorAll('.btn-delete').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const id = (e.currentTarget as HTMLButtonElement).dataset.id!;
|
||||||
|
await deleteReceipt(id);
|
||||||
|
await loadReceipts();
|
||||||
|
showToast('Receipt deleted');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupSearch() {
|
||||||
|
searchInput.addEventListener('input', () => {
|
||||||
|
const q = searchInput.value.toLowerCase().trim();
|
||||||
|
if (!q) {
|
||||||
|
renderReceipts(allReceipts);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filtered = allReceipts.filter(
|
||||||
|
(r) =>
|
||||||
|
r.title.toLowerCase().includes(q) ||
|
||||||
|
r.vendor.toLowerCase().includes(q) ||
|
||||||
|
r.notes.toLowerCase().includes(q) ||
|
||||||
|
getCategoryLabel(r.category).toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
renderReceipts(filtered);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Export ---
|
||||||
|
function setupExport() {
|
||||||
|
document.getElementById('export-csv')!.addEventListener('click', async () => {
|
||||||
|
const receipts = await getReceipts();
|
||||||
|
if (receipts.length === 0) {
|
||||||
|
showToast('No receipts to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const csv = exportToCsv(receipts);
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
downloadBlob(csv, `simplescan-receipts-${year}.csv`, 'text/csv');
|
||||||
|
showToast('CSV downloaded');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('export-pdf')!.addEventListener('click', async () => {
|
||||||
|
const receipts = await getReceipts();
|
||||||
|
if (receipts.length === 0) {
|
||||||
|
showToast('No receipts to export');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// PDF export via background service worker (offscreen or simple approach)
|
||||||
|
// For popup, we'll open options page with receipts pre-loaded
|
||||||
|
chrome.runtime.openOptionsPage();
|
||||||
|
showToast('Open Settings tab for PDF export');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('open-options')!.addEventListener('click', () => {
|
||||||
|
chrome.runtime.openOptionsPage();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Email scan ---
|
||||||
|
function setupEmailScan() {
|
||||||
|
scanEmailBtn.addEventListener('click', async () => {
|
||||||
|
emailResults.innerHTML = '<div class="loading">🔍 Scanning open email tabs...</div>';
|
||||||
|
try {
|
||||||
|
const tabs = await chrome.tabs.query({ url: ['*://mail.google.com/*', '*://outlook.live.com/*', '*://outlook.office.com/*', '*://outlook.office365.com/*'] });
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
emailResults.innerHTML = '<div class="email-empty">⚠️ No Gmail or Outlook tab found. Open your email and try again.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const results: { receipts: { subject: string; sender: string; amount?: number; vendor?: string; date: string }[] } = await chrome.tabs.sendMessage(tabs[0].id!, { action: 'scanEmails' });
|
||||||
|
if (!results || !results.receipts || results.receipts.length === 0) {
|
||||||
|
emailResults.innerHTML = '<div class="email-empty">No receipt emails detected on this page. Try opening a specific receipt email.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emailResults.innerHTML = results.receipts
|
||||||
|
.map(
|
||||||
|
(r, i) => `
|
||||||
|
<div class="email-item" data-index="${i}">
|
||||||
|
<div class="email-subject">${escapeHtml(r.subject)}</div>
|
||||||
|
<div class="email-meta">${escapeHtml(r.sender)} · ${r.date}</div>
|
||||||
|
${r.amount ? `<div class="email-amount">${formatMoney(r.amount, 'USD')}</div>` : ''}
|
||||||
|
<button class="btn-import" data-index="${i}">➕ Import as Receipt</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
emailResults.querySelectorAll('.btn-import').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
const idx = parseInt((e.currentTarget as HTMLButtonElement).dataset.index!, 10);
|
||||||
|
const er = results.receipts[idx];
|
||||||
|
const receipt: Receipt = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: er.subject,
|
||||||
|
amount: er.amount || 0,
|
||||||
|
currency: 'USD',
|
||||||
|
category: 'software',
|
||||||
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
vendor: er.vendor || er.sender,
|
||||||
|
notes: `Imported from email: ${er.subject}`,
|
||||||
|
source: 'email',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await saveReceipt(receipt);
|
||||||
|
showToast('Receipt imported from email');
|
||||||
|
tabButtons[1].click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
emailResults.innerHTML = `<div class="email-empty">Error scanning email: ${escapeHtml(String(err))}</div>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
function formatMoney(amount: number, currency: string): string {
|
||||||
|
const map: Record<string, string> = { USD: '$', EUR: '€', GBP: '£', CAD: 'C$', AUD: 'A$' };
|
||||||
|
const symbol = map[currency] || currency + ' ';
|
||||||
|
return symbol + amount.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso + 'T00:00:00');
|
||||||
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(message: string) {
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.remove('hidden');
|
||||||
|
setTimeout(() => toast.classList.add('hidden'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert';
|
||||||
|
|
||||||
|
// Test categories
|
||||||
|
import { TAX_CATEGORIES, getCategoryLabel, getCategoryIcon } from '../utils/categories.js';
|
||||||
|
|
||||||
|
describe('categories', () => {
|
||||||
|
it('has 10 tax categories', () => {
|
||||||
|
assert.strictEqual(TAX_CATEGORIES.length, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets correct label', () => {
|
||||||
|
assert.strictEqual(getCategoryLabel('software'), 'Software');
|
||||||
|
assert.strictEqual(getCategoryLabel('equipment'), 'Equipment');
|
||||||
|
assert.strictEqual(getCategoryLabel('nonexistent'), 'Other');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets correct icon', () => {
|
||||||
|
assert.strictEqual(getCategoryIcon('software'), '⚙️');
|
||||||
|
assert.strictEqual(getCategoryIcon('travel'), '✈️');
|
||||||
|
assert.strictEqual(getCategoryIcon('nonexistent'), '📦');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test export
|
||||||
|
import { exportToCsv } from '../utils/export.js';
|
||||||
|
import type { Receipt } from '../types.js';
|
||||||
|
|
||||||
|
describe('export', () => {
|
||||||
|
it('exports receipts to CSV', () => {
|
||||||
|
const receipts: Receipt[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Adobe CC',
|
||||||
|
amount: 59.99,
|
||||||
|
currency: 'USD',
|
||||||
|
category: 'software',
|
||||||
|
date: '2026-06-01',
|
||||||
|
vendor: 'Adobe',
|
||||||
|
notes: 'Monthly',
|
||||||
|
source: 'manual',
|
||||||
|
createdAt: '2026-06-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const csv = exportToCsv(receipts);
|
||||||
|
console.log('CSV output:', csv);
|
||||||
|
assert.ok(csv.includes('Date'), 'CSV should have Date header');
|
||||||
|
assert.ok(csv.includes('Adobe CC'), 'CSV should have title');
|
||||||
|
assert.ok(csv.includes('59.99'), 'CSV should have amount');
|
||||||
|
assert.ok(csv.includes('Software'), 'CSV should have category label');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes quotes in CSV', () => {
|
||||||
|
const receipts: Receipt[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'My "Special" Item',
|
||||||
|
amount: 10,
|
||||||
|
currency: 'USD',
|
||||||
|
category: 'other',
|
||||||
|
date: '2026-06-01',
|
||||||
|
vendor: 'Vendor',
|
||||||
|
notes: '',
|
||||||
|
source: 'manual',
|
||||||
|
createdAt: '2026-06-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const csv = exportToCsv(receipts);
|
||||||
|
assert.ok(csv.includes('""'), 'CSV should escape quotes');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
export interface Receipt {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
category: TaxCategory;
|
||||||
|
date: string; // ISO date
|
||||||
|
vendor: string;
|
||||||
|
notes: string;
|
||||||
|
imageDataUrl?: string;
|
||||||
|
source: 'manual' | 'email' | 'upload' | 'camera';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TaxCategory =
|
||||||
|
| 'equipment'
|
||||||
|
| 'home_office'
|
||||||
|
| 'software'
|
||||||
|
| 'internet_phone'
|
||||||
|
| 'travel'
|
||||||
|
| 'meals'
|
||||||
|
| 'professional_services'
|
||||||
|
| 'marketing'
|
||||||
|
| 'education'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
export interface CategoryInfo {
|
||||||
|
id: TaxCategory;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmailReceiptHint {
|
||||||
|
subject: string;
|
||||||
|
sender: string;
|
||||||
|
date: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
vendor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppSettings {
|
||||||
|
defaultCurrency: string;
|
||||||
|
taxYear: string;
|
||||||
|
ownerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
defaultCurrency: 'USD',
|
||||||
|
taxYear: new Date().getFullYear().toString(),
|
||||||
|
ownerName: '',
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { CategoryInfo } from '../types.js';
|
||||||
|
|
||||||
|
export const TAX_CATEGORIES: CategoryInfo[] = [
|
||||||
|
{
|
||||||
|
id: 'equipment',
|
||||||
|
label: 'Equipment',
|
||||||
|
description: 'Computers, cameras, tools, hardware',
|
||||||
|
icon: '💻',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'home_office',
|
||||||
|
label: 'Home Office',
|
||||||
|
description: 'Desk, chair, lighting, office supplies',
|
||||||
|
icon: '🏠',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'software',
|
||||||
|
label: 'Software',
|
||||||
|
description: 'Apps, subscriptions, SaaS tools',
|
||||||
|
icon: '⚙️',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'internet_phone',
|
||||||
|
label: 'Internet & Phone',
|
||||||
|
description: 'ISP, cell plan, domain hosting',
|
||||||
|
icon: '📡',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'travel',
|
||||||
|
label: 'Travel',
|
||||||
|
description: 'Flights, hotels, rideshare, mileage',
|
||||||
|
icon: '✈️',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'meals',
|
||||||
|
label: 'Meals',
|
||||||
|
description: 'Business meals (50% deductible)',
|
||||||
|
icon: '🍽️',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'professional_services',
|
||||||
|
label: 'Professional Services',
|
||||||
|
description: 'Lawyer, accountant, contractor payments',
|
||||||
|
icon: '📋',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'marketing',
|
||||||
|
label: 'Marketing',
|
||||||
|
description: 'Ads, design, copywriting, PR',
|
||||||
|
icon: '📢',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'education',
|
||||||
|
label: 'Education',
|
||||||
|
description: 'Courses, books, conferences',
|
||||||
|
icon: '📚',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'other',
|
||||||
|
label: 'Other',
|
||||||
|
description: 'Miscellaneous business expenses',
|
||||||
|
icon: '📦',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCategoryLabel(id: string): string {
|
||||||
|
return TAX_CATEGORIES.find((c) => c.id === id)?.label || 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCategoryIcon(id: string): string {
|
||||||
|
return TAX_CATEGORIES.find((c) => c.id === id)?.icon || '📦';
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Receipt } from '../types.js';
|
||||||
|
import { getCategoryLabel } from './categories.js';
|
||||||
|
|
||||||
|
export function exportToCsv(receipts: Receipt[]): string {
|
||||||
|
const headers = ['Date', 'Vendor', 'Title', 'Category', 'Amount', 'Currency', 'Notes', 'Source'];
|
||||||
|
const rows = receipts.map((r) => [
|
||||||
|
r.date,
|
||||||
|
r.vendor,
|
||||||
|
r.title,
|
||||||
|
getCategoryLabel(r.category),
|
||||||
|
r.amount.toFixed(2),
|
||||||
|
r.currency,
|
||||||
|
r.notes,
|
||||||
|
r.source,
|
||||||
|
]);
|
||||||
|
const csv = [headers, ...rows]
|
||||||
|
.map((row) =>
|
||||||
|
row
|
||||||
|
.map((cell) => {
|
||||||
|
const str = String(cell).replace(/"/g, '""');
|
||||||
|
return `"${str}"`;
|
||||||
|
})
|
||||||
|
.join(',')
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
return csv;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downloadBlob(content: BlobPart, filename: string, mimeType: string): void {
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Receipt, AppSettings } from '../types.js';
|
||||||
|
import { DEFAULT_SETTINGS } from '../types.js';
|
||||||
|
|
||||||
|
const RECEIPTS_KEY = 'simplescan_receipts';
|
||||||
|
const SETTINGS_KEY = 'simplescan_settings';
|
||||||
|
|
||||||
|
export async function getReceipts(): Promise<Receipt[]> {
|
||||||
|
const result = await chrome.storage.local.get(RECEIPTS_KEY);
|
||||||
|
return (result[RECEIPTS_KEY] as Receipt[]) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveReceipt(receipt: Receipt): Promise<void> {
|
||||||
|
const receipts = await getReceipts();
|
||||||
|
const existingIndex = receipts.findIndex((r) => r.id === receipt.id);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
receipts[existingIndex] = receipt;
|
||||||
|
} else {
|
||||||
|
receipts.unshift(receipt);
|
||||||
|
}
|
||||||
|
await chrome.storage.local.set({ [RECEIPTS_KEY]: receipts });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteReceipt(id: string): Promise<void> {
|
||||||
|
const receipts = await getReceipts();
|
||||||
|
const filtered = receipts.filter((r) => r.id !== id);
|
||||||
|
await chrome.storage.local.set({ [RECEIPTS_KEY]: filtered });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSettings(): Promise<AppSettings> {
|
||||||
|
const result = await chrome.storage.local.get(SETTINGS_KEY);
|
||||||
|
return (result[SETTINGS_KEY] as AppSettings) || { ...DEFAULT_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSettings(settings: AppSettings): Promise<void> {
|
||||||
|
await chrome.storage.local.set({ [SETTINGS_KEY]: settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAllReceipts(): Promise<void> {
|
||||||
|
await chrome.storage.local.remove(RECEIPTS_KEY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"types": ["chrome"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["src/test"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"outDir": "dist-test"
|
||||||
|
},
|
||||||
|
"include": ["src/test/**/*.ts", "src/utils/**/*.ts", "src/types.ts"],
|
||||||
|
"exclude": ["src/popup", "src/content", "src/background.ts", "src/options.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { crx } from '@crxjs/vite-plugin'
|
||||||
|
import manifest from './src/manifest.json' with { type: 'json' }
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
options: 'options.html',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [crx({ manifest })],
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user