TaxPack v1.0.0 - Gmail Receipt Auto-Extractor & Tax Report Generator
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.verdict
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Generate simple placeholder icons for TaxPack
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const zlib = require('zlib');
|
||||||
|
|
||||||
|
function createSimplePNG(size, r, g, b) {
|
||||||
|
const width = size;
|
||||||
|
const height = size;
|
||||||
|
|
||||||
|
const signature = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
|
||||||
|
const ihdrData = Buffer.alloc(13);
|
||||||
|
ihdrData.writeUInt32BE(width, 0);
|
||||||
|
ihdrData.writeUInt32BE(height, 4);
|
||||||
|
ihdrData[8] = 8;
|
||||||
|
ihdrData[9] = 2;
|
||||||
|
ihdrData[10] = 0;
|
||||||
|
ihdrData[11] = 0;
|
||||||
|
ihdrData[12] = 0;
|
||||||
|
|
||||||
|
const ihdr = createChunk('IHDR', ihdrData);
|
||||||
|
|
||||||
|
const rowSize = 1 + width * 3;
|
||||||
|
const imageData = Buffer.alloc(height * rowSize);
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
imageData[y * rowSize] = 0;
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const idx = y * rowSize + 1 + x * 3;
|
||||||
|
imageData[idx] = r;
|
||||||
|
imageData[idx + 1] = g;
|
||||||
|
imageData[idx + 2] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressed = zlib.deflateSync(imageData);
|
||||||
|
const idat = createChunk('IDAT', compressed);
|
||||||
|
const iend = createChunk('IEND', Buffer.alloc(0));
|
||||||
|
|
||||||
|
return Buffer.concat([signature, ihdr, idat, iend]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChunk(type, data) {
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(data.length, 0);
|
||||||
|
const typeBuf = Buffer.from(type, 'ascii');
|
||||||
|
const crc = zlib.crc32(Buffer.concat([typeBuf, data]));
|
||||||
|
const crcBuf = Buffer.alloc(4);
|
||||||
|
crcBuf.writeUInt32BE(crc >>> 0, 0);
|
||||||
|
return Buffer.concat([length, typeBuf, data, crcBuf]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconsDir = path.join(__dirname, 'public', 'icons');
|
||||||
|
fs.mkdirSync(iconsDir, { recursive: true });
|
||||||
|
|
||||||
|
const purple = { r: 107, g: 99, b: 182 };
|
||||||
|
|
||||||
|
[16, 48, 128].forEach(size => {
|
||||||
|
const png = createSimplePNG(size, purple.r, purple.g, purple.b);
|
||||||
|
fs.writeFileSync(path.join(iconsDir, `icon${size}.png`), png);
|
||||||
|
console.log(`Created icon${size}.png`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Icons generated successfully');
|
||||||
Generated
+3277
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "taxpack",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Gmail Receipt Auto-Extractor & Tax-Ready Report Generator",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@crxjs/vite-plugin": "^2.0.0-beta.28",
|
||||||
|
"@types/chrome": "^0.0.268",
|
||||||
|
"typescript": "^5.4.5",
|
||||||
|
"vite": "^5.2.0",
|
||||||
|
"vitest": "^1.6.0",
|
||||||
|
"jsdom": "^24.1.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"jspdf": "^2.5.1",
|
||||||
|
"jspdf-autotable": "^3.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 306 B |
Binary file not shown.
|
After Width: | Height: | Size: 79 B |
Binary file not shown.
|
After Width: | Height: | Size: 123 B |
@@ -0,0 +1,161 @@
|
|||||||
|
// TaxPack Background Service Worker
|
||||||
|
// Handles Gmail OAuth and message passing
|
||||||
|
|
||||||
|
import {
|
||||||
|
Receipt,
|
||||||
|
extractReceiptFromEmail,
|
||||||
|
exportToCSV,
|
||||||
|
generateTaxSummary,
|
||||||
|
} from './lib/receipt-utils';
|
||||||
|
|
||||||
|
// Store for extracted receipts
|
||||||
|
const STORAGE_KEY = 'taxpack_receipts';
|
||||||
|
|
||||||
|
// Get auth token from Chrome identity API
|
||||||
|
export async function getAuthToken(interactive: boolean = true): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const token = await chrome.identity.getAuthToken({ interactive });
|
||||||
|
return token.token ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Gmail messages with receipt indicators
|
||||||
|
export async function fetchReceiptEmails(token: string, maxResults: number = 50): Promise<any[]> {
|
||||||
|
const queries = [
|
||||||
|
'subject:receipt',
|
||||||
|
'subject:invoice',
|
||||||
|
'subject:order confirmation',
|
||||||
|
'subject:payment confirmation',
|
||||||
|
'subject:purchase confirmation',
|
||||||
|
'from:receipts',
|
||||||
|
'from:noreply',
|
||||||
|
];
|
||||||
|
|
||||||
|
const allMessages: any[] = [];
|
||||||
|
const seenIds = new Set<string>();
|
||||||
|
|
||||||
|
for (const query of queries) {
|
||||||
|
try {
|
||||||
|
const url = `https://www.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${Math.ceil(maxResults / queries.length)}`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
// Token expired, try to remove and re-auth
|
||||||
|
await chrome.identity.removeCachedAuthToken({ token });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.messages) {
|
||||||
|
for (const msg of data.messages) {
|
||||||
|
if (!seenIds.has(msg.id)) {
|
||||||
|
seenIds.add(msg.id);
|
||||||
|
allMessages.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Query failed: ${query}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allMessages.slice(0, maxResults);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full message details
|
||||||
|
export async function fetchMessageDetails(token: string, messageId: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const url = `https://www.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fetch message error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save receipts to storage
|
||||||
|
export async function saveReceipts(receipts: Receipt[]): Promise<void> {
|
||||||
|
const existing = await loadReceipts();
|
||||||
|
const merged = [...existing];
|
||||||
|
const seen = new Set(existing.map(r => r.id));
|
||||||
|
|
||||||
|
for (const receipt of receipts) {
|
||||||
|
if (!seen.has(receipt.id)) {
|
||||||
|
seen.add(receipt.id);
|
||||||
|
merged.push(receipt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await chrome.storage.local.set({ [STORAGE_KEY]: merged });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load receipts from storage
|
||||||
|
export async function loadReceipts(): Promise<Receipt[]> {
|
||||||
|
const result = await chrome.storage.local.get(STORAGE_KEY);
|
||||||
|
return result[STORAGE_KEY] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all receipts
|
||||||
|
export async function clearReceipts(): Promise<void> {
|
||||||
|
await chrome.storage.local.remove(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from popup
|
||||||
|
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (request.action === 'scanGmail') {
|
||||||
|
const token = await getAuthToken(request.interactive ?? true);
|
||||||
|
if (!token) {
|
||||||
|
sendResponse({ success: false, error: 'Authentication failed' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await fetchReceiptEmails(token, request.maxResults || 50);
|
||||||
|
const receipts: Receipt[] = [];
|
||||||
|
|
||||||
|
for (const msg of messages.slice(0, 20)) {
|
||||||
|
const details = await fetchMessageDetails(token, msg.id);
|
||||||
|
if (details) {
|
||||||
|
const receipt = extractReceiptFromEmail(details);
|
||||||
|
if (receipt) receipts.push(receipt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveReceipts(receipts);
|
||||||
|
sendResponse({ success: true, count: receipts.length, total: messages.length });
|
||||||
|
} else if (request.action === 'getReceipts') {
|
||||||
|
const receipts = await loadReceipts();
|
||||||
|
sendResponse({ success: true, receipts });
|
||||||
|
} else if (request.action === 'clearReceipts') {
|
||||||
|
await clearReceipts();
|
||||||
|
sendResponse({ success: true });
|
||||||
|
} else if (request.action === 'exportCSV') {
|
||||||
|
const receipts = await loadReceipts();
|
||||||
|
const csv = exportToCSV(receipts);
|
||||||
|
sendResponse({ success: true, csv });
|
||||||
|
} else if (request.action === 'getSummary') {
|
||||||
|
const receipts = await loadReceipts();
|
||||||
|
const summary = generateTaxSummary(receipts);
|
||||||
|
sendResponse({ success: true, summary, total: receipts.reduce((s, r) => s + r.amount, 0) });
|
||||||
|
} else {
|
||||||
|
sendResponse({ success: false, error: 'Unknown action' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
sendResponse({ success: false, error: String(error) });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return true; // Keep channel open for async
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#667eea"/>
|
||||||
|
<stop offset="100%" style="stop-color:#764ba2"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="128" height="128" rx="20" fill="url(#grad)"/>
|
||||||
|
<text x="64" y="82" font-size="72" text-anchor="middle" fill="white" font-family="sans-serif" font-weight="bold">$</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,189 @@
|
|||||||
|
// TaxPack Receipt Utilities - Pure functions for extraction, export, and summarization
|
||||||
|
|
||||||
|
export interface Receipt {
|
||||||
|
id: string;
|
||||||
|
merchant: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
date: string;
|
||||||
|
category: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
emailSubject: string;
|
||||||
|
source: 'gmail';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract receipt data from a Gmail message
|
||||||
|
*/
|
||||||
|
export function extractReceiptFromEmail(message: any): Receipt | null {
|
||||||
|
if (!message || !message.payload) return null;
|
||||||
|
|
||||||
|
const headers = message.payload.headers || [];
|
||||||
|
const subject = headers.find((h: any) => h.name === 'Subject')?.value || '';
|
||||||
|
const from = headers.find((h: any) => h.name === 'From')?.value || '';
|
||||||
|
const dateHeader = headers.find((h: any) => h.name === 'Date')?.value || '';
|
||||||
|
|
||||||
|
// Get email body
|
||||||
|
let body = '';
|
||||||
|
const getBody = (part: any): string => {
|
||||||
|
if (part.body?.data) {
|
||||||
|
try {
|
||||||
|
return atob(part.body.data.replace(/-/g, '+').replace(/_/g, '/'));
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (part.parts) {
|
||||||
|
for (const p of part.parts) {
|
||||||
|
const b = getBody(p);
|
||||||
|
if (b) return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
body = getBody(message.payload);
|
||||||
|
|
||||||
|
// Extract amount using regex patterns
|
||||||
|
const amountPatterns = [
|
||||||
|
/(?:total|amount|charged|paid|payment)[\s:]*[$€£]\s*([\d,]+\.?\d*)/i,
|
||||||
|
/[$€£]\s*([\d,]+\.?\d*)\s*(?:total|usd|eur|gbp)/i,
|
||||||
|
/(?:order total|grand total)[\s:]*[$€£]?\s*([\d,]+\.?\d*)/i,
|
||||||
|
/(?:price|cost)[\s:]*[$€£]?\s*([\d,]+\.?\d*)/i,
|
||||||
|
/\$\s*([\d,]+\.\d{2})/,
|
||||||
|
/([\d,]+\.\d{2})\s*(?:USD|EUR|GBP)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
let amount = 0;
|
||||||
|
for (const pattern of amountPatterns) {
|
||||||
|
const match = body.match(pattern) || subject.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
amount = parseFloat(match[1].replace(/,/g, ''));
|
||||||
|
if (amount > 0) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no amount found
|
||||||
|
if (amount <= 0) return null;
|
||||||
|
|
||||||
|
// Extract merchant
|
||||||
|
const merchantPatterns = [
|
||||||
|
/from\s+([A-Z][A-Za-z0-9\s&]+?)(?:\s[-—]|\s*<|\n|$)/i,
|
||||||
|
/(?:at|from)\s+([A-Z][A-Za-z0-9\s&]+?)(?:\s[-—]|\s*<|\n|$)/,
|
||||||
|
/([A-Z][A-Za-z0-9\s&]+?)\s*(?:receipt|invoice|order)/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
let merchant = 'Unknown Merchant';
|
||||||
|
for (const pattern of merchantPatterns) {
|
||||||
|
const match = subject.match(pattern) || from.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
merchant = match[1].trim();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Use from email domain as fallback
|
||||||
|
if (merchant === 'Unknown Merchant' && from.includes('@')) {
|
||||||
|
const domain = from.split('@')[1]?.split('>')[0];
|
||||||
|
if (domain) merchant = domain.replace(/\.com$|\.org$|\.net$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse date
|
||||||
|
let date = new Date().toISOString().split('T')[0];
|
||||||
|
try {
|
||||||
|
const parsed = new Date(dateHeader || Date.now());
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
date = parsed.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// use default
|
||||||
|
}
|
||||||
|
|
||||||
|
// Categorize based on keywords
|
||||||
|
const category = categorizeReceipt(subject + ' ' + body + ' ' + merchant);
|
||||||
|
|
||||||
|
// Extract payment method
|
||||||
|
const paymentPatterns = [
|
||||||
|
/(visa|mastercard|amex|american express|discover|paypal|apple pay|google pay)/i,
|
||||||
|
/ending in (\d{4})/i,
|
||||||
|
/\*\*\*\*(\d{4})/,
|
||||||
|
];
|
||||||
|
let paymentMethod = 'Unknown';
|
||||||
|
for (const pattern of paymentPatterns) {
|
||||||
|
const match = body.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
paymentMethod = match[1] || match[0];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: message.id,
|
||||||
|
merchant: merchant.substring(0, 100),
|
||||||
|
amount: Math.round(amount * 100) / 100,
|
||||||
|
currency: 'USD',
|
||||||
|
date,
|
||||||
|
category,
|
||||||
|
paymentMethod: paymentMethod.substring(0, 50),
|
||||||
|
emailSubject: subject.substring(0, 200),
|
||||||
|
source: 'gmail',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function categorizeReceipt(text: string): string {
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
const categories: [string, string[]][] = [
|
||||||
|
['Office Supplies', ['office', 'stationery', 'paper', 'ink', 'toner', 'staples', 'office depot']],
|
||||||
|
['Software & Tools', ['software', 'saas', 'subscription', 'license', 'app', 'tool', 'notion', 'slack', 'github']],
|
||||||
|
['Travel & Transport', ['flight', 'hotel', 'airbnb', 'uber', 'lyft', 'taxi', 'rental car', 'airline', 'booking']],
|
||||||
|
['Meals & Entertainment', ['restaurant', 'food', 'meal', 'coffee', 'starbucks', 'doordash', 'grubhub', 'uber eats']],
|
||||||
|
['Equipment & Hardware', ['computer', 'laptop', 'monitor', 'phone', 'camera', 'electronics', 'best buy', 'amazon']],
|
||||||
|
['Professional Services', ['legal', 'accounting', 'consulting', 'freelancer', 'upwork', 'fiverr', 'lawyer']],
|
||||||
|
['Marketing & Advertising', ['ad', 'advertising', 'marketing', 'facebook', 'google ads', 'campaign', 'promotion']],
|
||||||
|
['Insurance', ['insurance', 'policy', 'premium', 'coverage']],
|
||||||
|
['Utilities & Internet', ['utility', 'internet', 'phone bill', 'electric', 'gas', 'water']],
|
||||||
|
['Education & Training', ['course', 'training', 'certification', 'book', 'learning', 'udemy', 'coursera']],
|
||||||
|
['Health & Medical', ['medical', 'health', 'pharmacy', 'doctor', 'dental', 'vision']],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [cat, keywords] of categories) {
|
||||||
|
if (keywords.some(k => lower.includes(k))) return cat;
|
||||||
|
}
|
||||||
|
return 'Other';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export receipts to CSV format
|
||||||
|
*/
|
||||||
|
export function exportToCSV(receipts: Receipt[]): string {
|
||||||
|
if (receipts.length === 0) return '';
|
||||||
|
|
||||||
|
const headers = ['Date', 'Merchant', 'Category', 'Amount', 'Currency', 'Payment Method', 'Email Subject'];
|
||||||
|
const rows = receipts.map(r => [
|
||||||
|
r.date,
|
||||||
|
r.merchant,
|
||||||
|
r.category,
|
||||||
|
r.amount.toFixed(2),
|
||||||
|
r.currency,
|
||||||
|
r.paymentMethod,
|
||||||
|
r.emailSubject,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const escape = (s: string) => {
|
||||||
|
if (s.includes(',') || s.includes('"') || s.includes('\n')) {
|
||||||
|
return `"${s.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [headers.join(','), ...rows.map(row => row.map(escape).join(','))].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate tax summary grouped by category
|
||||||
|
*/
|
||||||
|
export function generateTaxSummary(receipts: Receipt[]): Record<string, number> {
|
||||||
|
const summary: Record<string, number> = {};
|
||||||
|
for (const receipt of receipts) {
|
||||||
|
summary[receipt.category] = (summary[receipt.category] || 0) + receipt.amount;
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "TaxPack - Receipt Extractor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Auto-extract receipts from Gmail and generate tax-ready reports",
|
||||||
|
"permissions": [
|
||||||
|
"identity",
|
||||||
|
"storage",
|
||||||
|
"activeTab"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://www.googleapis.com/*",
|
||||||
|
"https://mail.google.com/*"
|
||||||
|
],
|
||||||
|
"oauth2": {
|
||||||
|
"client_id": "TAXPACK_CLIENT_ID.apps.googleusercontent.com",
|
||||||
|
"scopes": [
|
||||||
|
"https://www.googleapis.com/auth/gmail.readonly"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"icons": {
|
||||||
|
"16": "icons/icon16.png",
|
||||||
|
"48": "icons/icon48.png",
|
||||||
|
"128": "icons/icon128.png"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>TaxPack</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
width: 380px;
|
||||||
|
min-height: 400px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #1a1a2e;
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 22px; margin-bottom: 4px; }
|
||||||
|
.header p { font-size: 12px; opacity: 0.9; }
|
||||||
|
.content { padding: 16px; }
|
||||||
|
.btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(102,126,234,0.3); }
|
||||||
|
.btn-secondary {
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
border: 2px solid #667eea;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: #f0f2ff; }
|
||||||
|
.btn-danger {
|
||||||
|
background: #fee;
|
||||||
|
color: #c33;
|
||||||
|
border: 1px solid #fcc;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.status.show { display: block; }
|
||||||
|
.status.success { background: #d4edda; color: #155724; }
|
||||||
|
.status.error { background: #f8d7da; color: #721c24; }
|
||||||
|
.status.info { background: #d1ecf1; color: #0c5460; }
|
||||||
|
.receipts-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.receipt-item {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.receipt-item:last-child { border-bottom: none; }
|
||||||
|
.receipt-merchant { font-weight: 600; color: #333; }
|
||||||
|
.receipt-amount { font-weight: 700; color: #667eea; }
|
||||||
|
.receipt-meta { color: #888; font-size: 11px; }
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 30px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.summary {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.summary h3 { font-size: 14px; margin-bottom: 8px; color: #555; }
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
.summary-row:last-child { border-bottom: none; font-weight: 700; color: #667eea; }
|
||||||
|
.spinner {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.spinner.show { display: block; }
|
||||||
|
.spinner::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #667eea;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<h1>TaxPack</h1>
|
||||||
|
<p>Gmail Receipt Extractor</p>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div id="status" class="status"></div>
|
||||||
|
<div id="spinner" class="spinner"></div>
|
||||||
|
|
||||||
|
<button id="btn-scan" class="btn btn-primary">Scan Gmail for Receipts</button>
|
||||||
|
<button id="btn-export-csv" class="btn btn-secondary">Export CSV</button>
|
||||||
|
<button id="btn-clear" class="btn btn-danger hidden">Clear All Receipts</button>
|
||||||
|
|
||||||
|
<div id="summary" class="summary hidden">
|
||||||
|
<h3>Tax Summary</h3>
|
||||||
|
<div id="summary-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="receipts-list" class="receipts-list">
|
||||||
|
<div class="empty-state">No receipts yet. Click "Scan Gmail" to start.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="./popup.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
// TaxPack Popup UI Controller
|
||||||
|
|
||||||
|
interface Receipt {
|
||||||
|
id: string;
|
||||||
|
merchant: string;
|
||||||
|
amount: number;
|
||||||
|
currency: string;
|
||||||
|
date: string;
|
||||||
|
category: string;
|
||||||
|
paymentMethod: string;
|
||||||
|
emailSubject: string;
|
||||||
|
source: 'gmail';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryData {
|
||||||
|
[category: string]: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $ = (id: string) => document.getElementById(id);
|
||||||
|
|
||||||
|
function showStatus(message: string, type: 'success' | 'error' | 'info' = 'info') {
|
||||||
|
const status = $('status');
|
||||||
|
if (!status) return;
|
||||||
|
status.textContent = message;
|
||||||
|
status.className = `status ${type} show`;
|
||||||
|
setTimeout(() => status.classList.remove('show'), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSpinner(show: boolean) {
|
||||||
|
const spinner = $('spinner');
|
||||||
|
if (spinner) spinner.classList.toggle('show', show);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCurrency(amount: number): string {
|
||||||
|
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReceipts() {
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({ action: 'getReceipts' });
|
||||||
|
if (response.success) {
|
||||||
|
renderReceipts(response.receipts);
|
||||||
|
renderSummary(response.receipts);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('Error loading receipts: ' + String(error), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReceipts(receipts: Receipt[]) {
|
||||||
|
const container = $('receipts-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (receipts.length === 0) {
|
||||||
|
container.innerHTML = '<div class="empty-state">No receipts yet. Click "Scan Gmail" to start.</div>';
|
||||||
|
$('btn-clear')?.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by date descending
|
||||||
|
const sorted = [...receipts].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||||
|
|
||||||
|
container.innerHTML = sorted.map(r => `
|
||||||
|
<div class="receipt-item">
|
||||||
|
<div>
|
||||||
|
<div class="receipt-merchant">${escapeHtml(r.merchant)}</div>
|
||||||
|
<div class="receipt-meta">${escapeHtml(r.date)} · ${escapeHtml(r.category)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="receipt-amount">${formatCurrency(r.amount)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
$('btn-clear')?.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSummary(receipts: Receipt[]) {
|
||||||
|
const summaryEl = $('summary');
|
||||||
|
const contentEl = $('summary-content');
|
||||||
|
if (!summaryEl || !contentEl) return;
|
||||||
|
|
||||||
|
if (receipts.length === 0) {
|
||||||
|
summaryEl.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary: SummaryData = {};
|
||||||
|
let total = 0;
|
||||||
|
for (const r of receipts) {
|
||||||
|
summary[r.category] = (summary[r.category] || 0) + r.amount;
|
||||||
|
total += r.amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = Object.entries(summary)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([cat, amt]) => `
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>${escapeHtml(cat)}</span>
|
||||||
|
<span>${formatCurrency(amt)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
contentEl.innerHTML = rows + `
|
||||||
|
<div class="summary-row">
|
||||||
|
<span>Total Deductible</span>
|
||||||
|
<span>${formatCurrency(total)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
summaryEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanGmail() {
|
||||||
|
showSpinner(true);
|
||||||
|
showStatus('Scanning Gmail for receipts...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({ action: 'scanGmail', interactive: true, maxResults: 50 });
|
||||||
|
if (response.success) {
|
||||||
|
showStatus(`Found ${response.count} receipts from ${response.total} emails!`, 'success');
|
||||||
|
await loadReceipts();
|
||||||
|
} else {
|
||||||
|
showStatus('Error: ' + (response.error || 'Unknown error'), 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('Scan failed: ' + String(error), 'error');
|
||||||
|
} finally {
|
||||||
|
showSpinner(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCSV() {
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({ action: 'exportCSV' });
|
||||||
|
if (response.success && response.csv) {
|
||||||
|
const blob = new Blob([response.csv], { type: 'text/csv' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `taxpack-receipts-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showStatus('CSV exported successfully!', 'success');
|
||||||
|
} else {
|
||||||
|
showStatus('No receipts to export', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('Export failed: ' + String(error), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearReceipts() {
|
||||||
|
if (!confirm('Clear all saved receipts? This cannot be undone.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await chrome.runtime.sendMessage({ action: 'clearReceipts' });
|
||||||
|
if (response.success) {
|
||||||
|
showStatus('All receipts cleared', 'info');
|
||||||
|
await loadReceipts();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('Error: ' + String(error), 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
loadReceipts();
|
||||||
|
|
||||||
|
$('btn-scan')?.addEventListener('click', scanGmail);
|
||||||
|
$('btn-export-csv')?.addEventListener('click', exportCSV);
|
||||||
|
$('btn-clear')?.addEventListener('click', clearReceipts);
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/// <reference types="vitest/globals" />
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock chrome APIs for tests
|
||||||
|
const mockChrome = {
|
||||||
|
identity: {
|
||||||
|
getAuthToken: vi.fn(),
|
||||||
|
removeCachedAuthToken: vi.fn(),
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
local: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
onMessage: { addListener: vi.fn() },
|
||||||
|
sendMessage: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore - assign mock to global chrome
|
||||||
|
(globalThis as any).chrome = mockChrome;
|
||||||
|
|
||||||
|
import {
|
||||||
|
extractReceiptFromEmail,
|
||||||
|
exportToCSV,
|
||||||
|
generateTaxSummary,
|
||||||
|
} from '../lib/receipt-utils';
|
||||||
|
|
||||||
|
describe('Receipt Extraction', () => {
|
||||||
|
it('extracts amount from receipt email', () => {
|
||||||
|
const message = {
|
||||||
|
id: 'msg1',
|
||||||
|
payload: {
|
||||||
|
headers: [
|
||||||
|
{ name: 'Subject', value: 'Your receipt from Amazon - $49.99' },
|
||||||
|
{ name: 'From', value: 'orders@amazon.com' },
|
||||||
|
{ name: 'Date', value: '2024-03-15T10:00:00Z' },
|
||||||
|
],
|
||||||
|
body: { data: '' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const receipt = extractReceiptFromEmail(message);
|
||||||
|
expect(receipt).not.toBeNull();
|
||||||
|
expect(receipt!.amount).toBe(49.99);
|
||||||
|
expect(receipt!.merchant).toBe('Amazon');
|
||||||
|
expect(receipt!.category).toBe('Equipment & Hardware');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts amount from body when not in subject', () => {
|
||||||
|
const message = {
|
||||||
|
id: 'msg2',
|
||||||
|
payload: {
|
||||||
|
headers: [
|
||||||
|
{ name: 'Subject', value: 'Order Confirmation' },
|
||||||
|
{ name: 'From', value: 'receipts@stripe.com' },
|
||||||
|
{ name: 'Date', value: '2024-01-20T14:30:00Z' },
|
||||||
|
],
|
||||||
|
body: { data: btoa('Thank you for your purchase. Total: $129.99 paid with Visa ending in 4242.') },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const receipt = extractReceiptFromEmail(message);
|
||||||
|
expect(receipt).not.toBeNull();
|
||||||
|
expect(receipt!.amount).toBe(129.99);
|
||||||
|
expect(receipt!.paymentMethod).toBe('Visa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for emails without amounts', () => {
|
||||||
|
const message = {
|
||||||
|
id: 'msg3',
|
||||||
|
payload: {
|
||||||
|
headers: [
|
||||||
|
{ name: 'Subject', value: 'Welcome to our newsletter' },
|
||||||
|
{ name: 'From', value: 'news@example.com' },
|
||||||
|
],
|
||||||
|
body: { data: btoa('Thanks for subscribing!') },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const receipt = extractReceiptFromEmail(message);
|
||||||
|
expect(receipt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('categorizes software subscriptions correctly', () => {
|
||||||
|
const message = {
|
||||||
|
id: 'msg4',
|
||||||
|
payload: {
|
||||||
|
headers: [
|
||||||
|
{ name: 'Subject', value: 'Your Slack subscription receipt' },
|
||||||
|
{ name: 'From', value: 'billing@slack.com' },
|
||||||
|
],
|
||||||
|
body: { data: btoa('Amount charged: $15.00') },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const receipt = extractReceiptFromEmail(message);
|
||||||
|
expect(receipt).not.toBeNull();
|
||||||
|
expect(receipt!.category).toBe('Software & Tools');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('categorizes meals correctly', () => {
|
||||||
|
const message = {
|
||||||
|
id: 'msg5',
|
||||||
|
payload: {
|
||||||
|
headers: [
|
||||||
|
{ name: 'Subject', value: 'Your DoorDash order receipt' },
|
||||||
|
{ name: 'From', value: 'receipts@doordash.com' },
|
||||||
|
],
|
||||||
|
body: { data: btoa('Total: $34.56') },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const receipt = extractReceiptFromEmail(message);
|
||||||
|
expect(receipt).not.toBeNull();
|
||||||
|
expect(receipt!.category).toBe('Meals & Entertainment');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CSV Export', () => {
|
||||||
|
it('exports receipts to CSV format', () => {
|
||||||
|
const receipts = [
|
||||||
|
{ id: '1', merchant: 'Amazon', amount: 49.99, currency: 'USD', date: '2024-03-15', category: 'Equipment', paymentMethod: 'Visa', emailSubject: 'Receipt', source: 'gmail' as const },
|
||||||
|
{ id: '2', merchant: 'Slack', amount: 15.00, currency: 'USD', date: '2024-03-16', category: 'Software', paymentMethod: 'Amex', emailSubject: 'Invoice', source: 'gmail' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const csv = exportToCSV(receipts);
|
||||||
|
expect(csv).toContain('Date,Merchant,Category,Amount,Currency,Payment Method,Email Subject');
|
||||||
|
expect(csv).toContain('2024-03-15,Amazon,Equipment,49.99,USD,Visa,Receipt');
|
||||||
|
expect(csv).toContain('2024-03-16,Slack,Software,15.00,USD,Amex,Invoice');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty receipts', () => {
|
||||||
|
const csv = exportToCSV([]);
|
||||||
|
expect(csv).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes commas in fields', () => {
|
||||||
|
const receipts = [
|
||||||
|
{ id: '1', merchant: 'Amazon, Inc.', amount: 49.99, currency: 'USD', date: '2024-03-15', category: 'Equipment', paymentMethod: 'Visa', emailSubject: 'Receipt, thanks!', source: 'gmail' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const csv = exportToCSV(receipts);
|
||||||
|
expect(csv).toContain('"Amazon, Inc."');
|
||||||
|
expect(csv).toContain('"Receipt, thanks!"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Tax Summary', () => {
|
||||||
|
it('groups receipts by category and sums amounts', () => {
|
||||||
|
const receipts = [
|
||||||
|
{ id: '1', merchant: 'A', amount: 50, currency: 'USD', date: '2024-03-15', category: 'Software', paymentMethod: 'V', emailSubject: 'R', source: 'gmail' as const },
|
||||||
|
{ id: '2', merchant: 'B', amount: 30, currency: 'USD', date: '2024-03-16', category: 'Software', paymentMethod: 'V', emailSubject: 'R', source: 'gmail' as const },
|
||||||
|
{ id: '3', merchant: 'C', amount: 20, currency: 'USD', date: '2024-03-17', category: 'Meals', paymentMethod: 'V', emailSubject: 'R', source: 'gmail' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = generateTaxSummary(receipts);
|
||||||
|
expect(summary['Software']).toBe(80);
|
||||||
|
expect(summary['Meals']).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object for no receipts', () => {
|
||||||
|
const summary = generateTaxSummary([]);
|
||||||
|
expect(Object.keys(summary)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2020", "DOM"],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { crx } from '@crxjs/vite-plugin';
|
||||||
|
import manifest from './src/manifest.json';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
popup: 'src/popup/popup.html',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [crx({ manifest })],
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user