commit 326d4084491f5f733c57ce6284874e0ee8e8c57f Author: Bun Bun Date: Fri Jun 19 18:33:39 2026 +0000 InvoiceChaser v1.0.0 — Freelancer Payment Tracker & Auto Follow-Up Chrome Extension diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..93097c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.verdict diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..da2fe20 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1500 @@ +{ + "name": "invoicechaser", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "invoicechaser", + "version": "1.0.0", + "dependencies": { + "@types/node": "^26.0.0" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.268", + "typescript": "^5.4.5", + "vite": "^5.2.11" + } + }, + "node_modules/@crxjs/vite-plugin": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@crxjs/vite-plugin/-/vite-plugin-2.7.0.tgz", + "integrity": "sha512-NN98fUgJOEiVBshELJnYCW54+K2Yy067++/K+RdxclHsyG08ea+Em3lJJTubtPTCl2bECyp4+K69oT+izilnNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webcomponents/custom-elements": "^1.5.0", + "acorn-walk": "^8.3.5", + "convert-source-map": "^1.7.0", + "debug": "^4.3.3", + "es-module-lexer": "^0.10.0", + "fs-extra": "^10.0.1", + "jsesc": "^3.0.2", + "magic-string": "^0.30.12", + "node-html-parser": "^7.1.0", + "pathe": "^2.0.1", + "picocolors": "^1.1.1", + "rollup": "2.80.0", + "rxjs": "7.5.7", + "tinyglobby": "^0.2.17" + }, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chrome": { + "version": "0.0.268", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.0.268.tgz", + "integrity": "sha512-7N1QH9buudSJ7sI8Pe4mBHJr5oZ48s0hcanI9w3wgijAlv1OZNUZve9JR4x42dn5lJ5Sm87V1JNfnoh10EnQlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filesystem": "*", + "@types/har-format": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/filesystem": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", + "integrity": "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/filewriter": "*" + } + }, + "node_modules/@types/filewriter": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/filewriter/-/filewriter-0.0.33.tgz", + "integrity": "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/har-format": { + "version": "1.2.16", + "resolved": "https://registry.npmjs.org/@types/har-format/-/har-format-1.2.16.tgz", + "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.0.tgz", + "integrity": "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@webcomponents/custom-elements": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@webcomponents/custom-elements/-/custom-elements-1.6.0.tgz", + "integrity": "sha512-CqTpxOlUCPWRNUPZDxT5v2NnHXA4oox612iUGnmTUGQFhZ1Gkj8kirtl/2wcF6MqX7+PqqicZzOCBKKfIn0dww==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.10.5.tgz", + "integrity": "sha512-+7IwY/kiGAacQfY+YBhKMvEmyAJnw5grTUgjG85Pe7vcUI/6b7pZjZG8nQ7+48YhzEAEqrEgD2dCz/JIK+AYvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.13", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.13.tgz", + "integrity": "sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-html-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "2.80.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-2.80.0.tgz", + "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", + "dev": true, + "license": "MIT", + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=10.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/rxjs": { + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.5.7.tgz", + "integrity": "sha512-z9MzKh/UcOqB3i20H6rtrlaE/CgjLOvheWK/9ILrbhROGTweAi1BaFsTT9FbwZi5Trr1qNRs+MXkhmR06awzQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..bb6f45b --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "invoicechaser", + "version": "1.0.0", + "description": "Freelancer Payment Tracker & Auto Follow-Up Chrome Extension", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build && tsc", + "test": "node --test dist/test/**/*.test.js", + "lint": "tsc --noEmit" + }, + "devDependencies": { + "@crxjs/vite-plugin": "^2.0.0-beta.28", + "@types/chrome": "^0.0.268", + "typescript": "^5.4.5", + "vite": "^5.2.11" + }, + "dependencies": { + "@types/node": "^26.0.0" + } +} diff --git a/public/icons/icon128.png b/public/icons/icon128.png new file mode 100644 index 0000000..9e043b0 Binary files /dev/null and b/public/icons/icon128.png differ diff --git a/public/icons/icon16.png b/public/icons/icon16.png new file mode 100644 index 0000000..c00ae1d Binary files /dev/null and b/public/icons/icon16.png differ diff --git a/public/icons/icon48.png b/public/icons/icon48.png new file mode 100644 index 0000000..1495e97 Binary files /dev/null and b/public/icons/icon48.png differ diff --git a/scripts/gen-icons.cjs b/scripts/gen-icons.cjs new file mode 100644 index 0000000..6f72e39 --- /dev/null +++ b/scripts/gen-icons.cjs @@ -0,0 +1,77 @@ +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); + +function crc32(buf) { + const table = new Int32Array(256); + 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 (let i = 0; i < buf.length; i++) { + crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ -1) >>> 0; +} + +function makeChunk(type, data) { + const typeBuf = Buffer.from(type, "ascii"); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(data.length, 0); + const crcData = Buffer.concat([typeBuf, data]); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc32(crcData), 0); + return Buffer.concat([lenBuf, typeBuf, data, crcBuf]); +} + +function createSolidPng(size, r, g, b) { + const width = size; + const height = size; + const bitDepth = 8; + const colorType = 2; // RGB + + 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.writeUInt8(bitDepth, 8); + ihdrData.writeUInt8(colorType, 9); + ihdrData.writeUInt8(0, 10); + ihdrData.writeUInt8(0, 11); + ihdrData.writeUInt8(0, 12); + const ihdrChunk = makeChunk("IHDR", ihdrData); + + const rowSize = 1 + width * 3; + const imageData = Buffer.alloc(rowSize * height); + for (let y = 0; y < height; y++) { + imageData[y * rowSize] = 0; + for (let x = 0; x < width; x++) { + const offset = y * rowSize + 1 + x * 3; + imageData[offset] = r; + imageData[offset + 1] = g; + imageData[offset + 2] = b; + } + } + + const compressed = zlib.deflateRawSync(imageData); + const idatChunk = makeChunk("IDAT", compressed); + const iendChunk = makeChunk("IEND", Buffer.alloc(0)); + + return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk]); +} + +const iconsDir = path.join(__dirname, "..", "src", "icons"); +fs.mkdirSync(iconsDir, { recursive: true }); + +const sizes = [16, 48, 128]; +for (const size of sizes) { + const png = createSolidPng(size, 34, 197, 94); + fs.writeFileSync(path.join(iconsDir, `icon${size}.png`), png); +} + +console.log("Icons generated successfully"); diff --git a/scripts/gen-icons.ts b/scripts/gen-icons.ts new file mode 100644 index 0000000..8033613 --- /dev/null +++ b/scripts/gen-icons.ts @@ -0,0 +1,107 @@ +// Generate simple 1-color PNG icons using raw buffer construction +// PNG signature + IHDR + IDAT + IEND for a solid-color square + +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +function createSolidPng(size: number, r: number, g: number, b: number): Buffer { + // Simple zlib compression for raw RGBA data + // This is a minimal approach; for solid colors we can construct a valid PNG + // Using a pre-built minimal solid color PNG template would be easier + // Let's use a small helper that creates a valid PNG via canvas-like approach + // Actually, let's just write a simple PNG with the minimal required chunks + + const width = size; + const height = size; + const bitDepth = 8; + const colorType = 2; // RGB + + // PNG signature + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + // IHDR chunk + const ihdrData = Buffer.alloc(13); + ihdrData.writeUInt32BE(width, 0); + ihdrData.writeUInt32BE(height, 4); + ihdrData.writeUInt8(bitDepth, 8); + ihdrData.writeUInt8(colorType, 9); + ihdrData.writeUInt8(0, 10); // compression + ihdrData.writeUInt8(0, 11); // filter + ihdrData.writeUInt8(0, 12); // interlace + + const ihdrChunk = makeChunk("IHDR", ihdrData); + + // IDAT chunk - raw image data with filter byte + // Each row: 1 filter byte (0 = none) + width * 3 bytes (RGB) + const rowSize = 1 + width * 3; + const imageData = Buffer.alloc(rowSize * height); + for (let y = 0; y < height; y++) { + imageData[y * rowSize] = 0; // filter: none + for (let x = 0; x < width; x++) { + const offset = y * rowSize + 1 + x * 3; + imageData[offset] = r; + imageData[offset + 1] = g; + imageData[offset + 2] = b; + } + } + + const compressed = zlibDeflate(imageData); + const idatChunk = makeChunk("IDAT", compressed); + + // IEND chunk + const iendChunk = makeChunk("IEND", Buffer.alloc(0)); + + return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk]); +} + +function makeChunk(type: string, data: Buffer): Buffer { + const typeBuf = Buffer.from(type, "ascii"); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(data.length, 0); + const crcBuf = Buffer.alloc(4); + const crcData = Buffer.concat([typeBuf, data]); + crcBuf.writeInt32BE(crc32(crcData), 0); + return Buffer.concat([lenBuf, typeBuf, data, crcBuf]); +} + +function crc32(buf: Buffer): number { + const table = new Int32Array(256); + 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 (let i = 0; i < buf.length; i++) { + crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ -1) >>> 0; +} + +function zlibDeflate(data: Buffer): Buffer { + // Minimal deflate: no compression blocks (type 0) for small data + // For larger images, we need proper zlib. Let's use Node's built-in zlib. + import("zlib").then((zlib) => { + // Not needed - we'll do this synchronously via a different approach + }); + // Actually, we can use the zlib module directly since we're in Node + const zlib = require("zlib"); + return zlib.deflateRawSync(data); +} + +const iconsDir = path.join(__dirname, "..", "src", "icons"); +fs.mkdirSync(iconsDir, { recursive: true }); + +// Green color (#22c55e) for InvoiceChaser +const sizes = [16, 48, 128]; +for (const size of sizes) { + const png = createSolidPng(size, 34, 197, 94); + fs.writeFileSync(path.join(iconsDir, `icon${size}.png`), png); +} + +console.log("Icons generated successfully"); diff --git a/src/background.ts b/src/background.ts new file mode 100644 index 0000000..ace380d --- /dev/null +++ b/src/background.ts @@ -0,0 +1,165 @@ +import { getInvoices, saveInvoice, getSettings } from "./lib/storage.js"; +import { isOverdue, daysSince, generateInvoiceId } from "./lib/invoice-detector.js"; +import { shouldFollowUp, generateFollowUp } from "./lib/follow-up.js"; +import type { Invoice } from "./lib/types.js"; + +// Alarm names +const CHECK_FOLLOWUPS = "check-followups"; +const CHECK_OVERDUE = "check-overdue"; + +// Initialize alarms on extension install +chrome.runtime.onInstalled.addListener(() => { + chrome.alarms.create(CHECK_FOLLOWUPS, { periodInMinutes: 60 }); // every hour + chrome.alarms.create(CHECK_OVERDUE, { periodInMinutes: 360 }); // every 6 hours + console.log("[InvoiceChaser] Alarms initialized"); +}); + +// Handle alarm triggers +chrome.alarms.onAlarm.addListener(async (alarm) => { + if (alarm.name === CHECK_FOLLOWUPS) { + await checkFollowUps(); + } else if (alarm.name === CHECK_OVERDUE) { + await checkOverdue(); + } +}); + +// Handle messages from content scripts and popup +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + handleMessage(message, sender).then(sendResponse).catch((err) => sendResponse({ error: err.message })); + return true; // async response +}); + +async function handleMessage( + message: { type: string; data?: unknown }, + _sender: chrome.runtime.MessageSender +): Promise { + switch (message.type) { + case "detected-invoice": { + const detected = message.data as { + clientEmail: string; + clientName: string; + subject: string; + amount: number; + currency: string; + dueDate: string; + sentDate: string; + source: "gmail" | "outlook"; + sourceThreadId: string; + }; + + const invoices = await getInvoices(); + const existing = invoices.find( + (i) => i.sourceThreadId === detected.sourceThreadId + ); + if (existing) return { status: "exists", id: existing.id }; + + const settings = await getSettings(); + const invoice: Invoice = { + id: generateInvoiceId(), + clientEmail: detected.clientEmail, + clientName: detected.clientName, + subject: detected.subject, + amount: detected.amount, + currency: detected.currency, + dueDate: detected.dueDate, + sentDate: detected.sentDate, + status: isOverdue(detected.dueDate) ? "overdue" : "pending", + source: detected.source, + sourceThreadId: detected.sourceThreadId, + followUpCount: 0, + followUpSchedule: settings.followUpDays, + notes: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await saveInvoice(invoice); + return { status: "saved", id: invoice.id }; + } + + case "detected-payment": { + const payment = message.data as { invoiceId: string; provider: string }; + const invoices = await getInvoices(); + const invoice = invoices.find((i) => i.id === payment.invoiceId); + if (!invoice) return { status: "not-found" }; + + const updated: Invoice = { + ...invoice, + status: "paid", + paymentMethod: payment.provider as "stripe" | "paypal" | "bank" | "other", + paymentDate: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await saveInvoice(updated); + return { status: "marked-paid" }; + } + + case "get-follow-ups": { + const invoices = await getInvoices(); + const settings = await getSettings(); + const followUps = []; + for (const invoice of invoices) { + if (shouldFollowUp(invoice)) { + const followUp = generateFollowUp(invoice, settings); + if (followUp) { + followUps.push({ invoice, followUp }); + } + } + } + return { followUps }; + } + + case "mark-sent-follow-up": { + const { invoiceId } = message.data as { invoiceId: string }; + const invoices = await getInvoices(); + const invoice = invoices.find((i) => i.id === invoiceId); + if (!invoice) return { status: "not-found" }; + + const updated: Invoice = { + ...invoice, + followUpCount: invoice.followUpCount + 1, + lastFollowUpDate: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await saveInvoice(updated); + return { status: "updated" }; + } + + default: + return { status: "unknown" }; + } +} + +async function checkFollowUps(): Promise { + const invoices = await getInvoices(); + const settings = await getSettings(); + if (!settings.enableFollowUpGeneration) return; + + let count = 0; + for (const invoice of invoices) { + if (shouldFollowUp(invoice)) { + count++; + } + } + + if (count > 0) { + chrome.action.setBadgeText({ text: count > 9 ? "9+" : String(count) }); + chrome.action.setBadgeBackgroundColor({ color: "#ef4444" }); + } else { + chrome.action.setBadgeText({ text: "" }); + } +} + +async function checkOverdue(): Promise { + const invoices = await getInvoices(); + for (const invoice of invoices) { + if (invoice.status === "pending" && isOverdue(invoice.dueDate)) { + const updated: Invoice = { + ...invoice, + status: "overdue", + updatedAt: new Date().toISOString(), + }; + await saveInvoice(updated); + } + } +} diff --git a/src/content/gmail.ts b/src/content/gmail.ts new file mode 100644 index 0000000..52cf908 --- /dev/null +++ b/src/content/gmail.ts @@ -0,0 +1,111 @@ +// Content script for Gmail invoice detection +// Scans Gmail DOM for invoice-related emails and sends them to background + +import { detectInvoiceFromEmail } from "../lib/invoice-detector.js"; +import { scanForPaymentConfirmation } from "../lib/stripe-paypal.js"; + +let lastProcessedThreads = new Set(); + +function scanGmail() { + // Look for email rows in the Gmail list view + const emailRows = Array.from(document.querySelectorAll("tr[draggable='true']")); + + for (const row of emailRows) { + const subjectEl = row.querySelector("[data-tooltip]"); + const senderEl = row.querySelector("[email]"); + const dateEl = row.querySelector("[title]"); + + if (!subjectEl || !senderEl) continue; + + const subject = subjectEl.textContent || ""; + const fromEmail = senderEl.getAttribute("email") || ""; + const fromName = senderEl.getAttribute("name") || ""; + const date = dateEl?.getAttribute("title") || new Date().toISOString(); + const threadId = row.getAttribute("data-thread-id") || subject + fromEmail; + + if (lastProcessedThreads.has(threadId)) continue; + lastProcessedThreads.add(threadId); + + // For payment confirmations, we need minimal info + if (subject.toLowerCase().includes("payment") || subject.toLowerCase().includes("receipt")) { + const confirmation = scanForPaymentConfirmation(subject, "", fromEmail); + if (confirmation) { + chrome.runtime.sendMessage({ + type: "detected-payment", + data: { invoiceId: "", provider: confirmation.provider } + }); + continue; + } + } + + const detected = detectInvoiceFromEmail(subject, "", fromEmail, fromName, date, threadId); + if (detected) { + chrome.runtime.sendMessage({ + type: "detected-invoice", + data: { + clientEmail: detected.clientEmail, + clientName: detected.clientName, + subject: detected.subject, + amount: detected.amount, + currency: detected.currency, + dueDate: detected.dueDate, + sentDate: detected.sentDate, + source: "gmail" as const, + sourceThreadId: detected.sourceThreadId, + } + }).then((response) => { + console.log("[InvoiceChaser] Gmail detection:", response); + }); + } + } +} + +// Also scan the open email view (if user is reading an individual email) +function scanOpenEmail() { + const subjectEl = document.querySelector("h2[data-thread-perm-id]"); + if (!subjectEl) return; + + const subject = subjectEl.textContent || ""; + const fromEl = document.querySelector("[email]"); + const fromEmail = fromEl?.getAttribute("email") || ""; + const fromName = fromEl?.getAttribute("name") || ""; + const dateEl = document.querySelector("[title]"); + const date = dateEl?.getAttribute("title") || new Date().toISOString(); + const threadId = subjectEl.getAttribute("data-thread-perm-id") || subject + fromEmail; + + if (lastProcessedThreads.has(threadId)) return; + lastProcessedThreads.add(threadId); + + const bodyEl = document.querySelector(".a3s.aiL"); + const body = bodyEl?.textContent || ""; + + const detected = detectInvoiceFromEmail(subject, body, fromEmail, fromName, date, threadId); + if (detected) { + chrome.runtime.sendMessage({ + type: "detected-invoice", + data: { + clientEmail: detected.clientEmail, + clientName: detected.clientName, + subject: detected.subject, + amount: detected.amount, + currency: detected.currency, + dueDate: detected.dueDate, + sentDate: detected.sentDate, + source: "gmail" as const, + sourceThreadId: detected.sourceThreadId, + } + }); + } +} + +// Scan periodically +setInterval(() => { + scanGmail(); + scanOpenEmail(); +}, 5000); + +// Initial scan +scanGmail(); +scanOpenEmail(); + +console.log("[InvoiceChaser] Gmail content script loaded"); diff --git a/src/content/outlook.ts b/src/content/outlook.ts new file mode 100644 index 0000000..e750ac9 --- /dev/null +++ b/src/content/outlook.ts @@ -0,0 +1,111 @@ +// Content script for Outlook invoice detection +// Scans Outlook web DOM for invoice-related emails + +import { detectInvoiceFromEmail } from "../lib/invoice-detector.js"; +import { scanForPaymentConfirmation } from "../lib/stripe-paypal.js"; + +let lastProcessedThreads = new Set(); + +function scanOutlook() { + // Outlook list view items + const emailItems = Array.from(document.querySelectorAll("[role='listitem']")); + + for (const item of emailItems) { + const subjectEl = item.querySelector("[title]"); + const senderEl = item.querySelector("[aria-label]"); + + if (!subjectEl || !senderEl) continue; + + const subject = subjectEl.textContent || ""; + const senderText = senderEl.getAttribute("aria-label") || ""; + const fromEmail = extractEmail(senderText); + const fromName = senderText.split("<")[0].trim(); + const date = new Date().toISOString(); + const threadId = item.getAttribute("data-id") || subject + fromEmail; + + if (lastProcessedThreads.has(threadId)) continue; + lastProcessedThreads.add(threadId); + + if (subject.toLowerCase().includes("payment") || subject.toLowerCase().includes("receipt")) { + const confirmation = scanForPaymentConfirmation(subject, "", fromEmail); + if (confirmation) { + chrome.runtime.sendMessage({ + type: "detected-payment", + data: { invoiceId: "", provider: confirmation.provider } + }); + continue; + } + } + + const detected = detectInvoiceFromEmail(subject, "", fromEmail, fromName, date, threadId); + if (detected) { + chrome.runtime.sendMessage({ + type: "detected-invoice", + data: { + clientEmail: detected.clientEmail, + clientName: detected.clientName, + subject: detected.subject, + amount: detected.amount, + currency: detected.currency, + dueDate: detected.dueDate, + sentDate: detected.sentDate, + source: "outlook" as const, + sourceThreadId: detected.sourceThreadId, + } + }).then((response) => { + console.log("[InvoiceChaser] Outlook detection:", response); + }); + } + } +} + +function scanOpenEmail() { + const subjectEl = document.querySelector("[role='heading']"); + if (!subjectEl) return; + + const subject = subjectEl.textContent || ""; + const fromEl = document.querySelector("[aria-label*='From']"); + const fromEmail = fromEl ? extractEmail(fromEl.textContent || "") : ""; + const fromName = fromEl ? (fromEl.textContent || "").split("<")[0].trim() : ""; + const date = new Date().toISOString(); + const threadId = subject + fromEmail; + + if (lastProcessedThreads.has(threadId)) return; + lastProcessedThreads.add(threadId); + + const bodyEl = document.querySelector("[role='document']"); + const body = bodyEl?.textContent || ""; + + const detected = detectInvoiceFromEmail(subject, body, fromEmail, fromName, date, threadId); + if (detected) { + chrome.runtime.sendMessage({ + type: "detected-invoice", + data: { + clientEmail: detected.clientEmail, + clientName: detected.clientName, + subject: detected.subject, + amount: detected.amount, + currency: detected.currency, + dueDate: detected.dueDate, + sentDate: detected.sentDate, + source: "outlook" as const, + sourceThreadId: detected.sourceThreadId, + } + }); + } +} + +function extractEmail(text: string): string { + const match = text.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/); + return match ? match[0] : ""; +} + +setInterval(() => { + scanOutlook(); + scanOpenEmail(); +}, 5000); + +scanOutlook(); +scanOpenEmail(); + +console.log("[InvoiceChaser] Outlook content script loaded"); diff --git a/src/icons/icon128.png b/src/icons/icon128.png new file mode 100644 index 0000000..9e043b0 Binary files /dev/null and b/src/icons/icon128.png differ diff --git a/src/icons/icon16.png b/src/icons/icon16.png new file mode 100644 index 0000000..c00ae1d Binary files /dev/null and b/src/icons/icon16.png differ diff --git a/src/icons/icon48.png b/src/icons/icon48.png new file mode 100644 index 0000000..1495e97 Binary files /dev/null and b/src/icons/icon48.png differ diff --git a/src/lib/follow-up.ts b/src/lib/follow-up.ts new file mode 100644 index 0000000..295b41e --- /dev/null +++ b/src/lib/follow-up.ts @@ -0,0 +1,74 @@ +import type { Invoice, Settings } from "./types.js"; +import { daysOverdue, daysSince } from "./invoice-detector.js"; + +export interface FollowUpEmail { + subject: string; + body: string; + tone: "polite" | "firm" | "urgent"; + daysAfter: number; +} + +export function generateFollowUp(invoice: Invoice, settings: Settings): FollowUpEmail | null { + const daysSinceSent = daysSince(invoice.sentDate); + const daysOver = daysOverdue(invoice.dueDate); + + // Determine which follow-up stage we're at + const schedule = invoice.followUpSchedule; + let stage = -1; + for (let i = 0; i < schedule.length; i++) { + if (daysSinceSent >= schedule[i] && invoice.followUpCount <= i) { + stage = i; + break; + } + } + + if (stage === -1) return null; + + const daysAfter = schedule[stage]; + const tone = stage === 0 ? "polite" : stage === 1 ? "firm" : "urgent"; + const userName = settings.userName || "there"; + const clientName = invoice.clientName || "there"; + + const subject = `Re: ${invoice.subject}`; + + let body: string; + + if (tone === "polite") { + body = `Hi ${clientName},\n\nI hope you're doing well. I wanted to follow up on the invoice I sent on ${formatDate(invoice.sentDate)} for ${formatCurrency(invoice.amount, invoice.currency)}.\n\nThe payment was due on ${formatDate(invoice.dueDate)}. If you've already sent it, please disregard this message.\n\nBest regards,\n${userName}`; + } else if (tone === "firm") { + body = `Hi ${clientName},\n\nThis is a friendly reminder that the invoice I sent on ${formatDate(invoice.sentDate)} for ${formatCurrency(invoice.amount, invoice.currency)} is now ${daysOver} days overdue.\n\nPlease let me know if there's anything preventing payment so we can resolve it.\n\nBest,\n${userName}`; + } else { + body = `Hi ${clientName},\n\nThe invoice for ${formatCurrency(invoice.amount, invoice.currency)} sent on ${formatDate(invoice.sentDate)} is now ${daysOver} days overdue.\n\nThis is impacting my cash flow. Please process this payment urgently or contact me to discuss a payment plan.\n\nRegards,\n${userName}`; + } + + return { subject, body, tone, daysAfter }; +} + +export function shouldFollowUp(invoice: Invoice): boolean { + if (invoice.status !== "pending" && invoice.status !== "overdue") return false; + + const daysSinceSent = daysSince(invoice.sentDate); + const schedule = invoice.followUpSchedule; + + for (let i = 0; i < schedule.length; i++) { + if (daysSinceSent >= schedule[i] && invoice.followUpCount <= i) { + return true; + } + } + return false; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function formatCurrency(amount: number, currency: string): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + }).format(amount); +} diff --git a/src/lib/invoice-detector.ts b/src/lib/invoice-detector.ts new file mode 100644 index 0000000..c998ec6 --- /dev/null +++ b/src/lib/invoice-detector.ts @@ -0,0 +1,139 @@ +import type { Invoice } from "./types.js"; + +const INVOICE_KEYWORDS = [ + "invoice", "invoiced", "bill", "billing", "payment due", + "amount due", "total due", "please pay", "remit payment", + "outstanding balance", "payment request" +]; + +const PAID_KEYWORDS = [ + "payment received", "paid", "thank you for your payment", + "payment confirmation", "receipt", "payment successful", + "we received your payment", "your payment has been processed" +]; + +const CURRENCY_REGEX = /[$€£¥]\s?([\d,]+\.?\d*)|([\d,]+\.?\d*)\s?(USD|EUR|GBP|CAD|AUD)/gi; +const DATE_REGEX = /(?:due|by|before)\s*(?:date[:\s]*)?(\d{1,2}[\/\-.]\d{1,2}[\/\-.]\d{2,4})/i; + +export interface DetectedInvoice { + clientEmail: string; + clientName: string; + subject: string; + amount: number; + currency: string; + dueDate: string; + sentDate: string; + sourceThreadId: string; + isPaidConfirmation: boolean; +} + +export function detectInvoiceFromEmail( + subject: string, + body: string, + fromEmail: string, + fromName: string, + date: string, + threadId: string +): DetectedInvoice | null { + const text = `${subject} ${body}`.toLowerCase(); + + // Check if this is a payment confirmation + const isPaid = PAID_KEYWORDS.some((kw) => text.includes(kw.toLowerCase())); + + // Check for invoice keywords (or payment confirmation) + const hasInvoiceKeyword = INVOICE_KEYWORDS.some((kw) => text.includes(kw.toLowerCase())); + + if (!hasInvoiceKeyword && !isPaid) { + return null; + } + + // Extract amount + const amount = extractAmount(subject + " " + body); + + // Extract currency + const currency = extractCurrency(subject + " " + body) || "USD"; + + // Extract due date + const dueDate = extractDueDate(body) || extractDueDate(subject) || getDefaultDueDate(date); + + return { + clientEmail: fromEmail, + clientName: fromName || fromEmail.split("@")[0], + subject, + amount, + currency, + dueDate, + sentDate: date, + sourceThreadId: threadId, + isPaidConfirmation: isPaid, + }; +} + +function extractAmount(text: string): number { + const matches = text.match(CURRENCY_REGEX); + if (!matches) return 0; + + // Pick the largest amount found + let maxAmount = 0; + for (const match of matches) { + const num = parseFloat(match.replace(/[^\d.]/g, "")); + if (!isNaN(num) && num > maxAmount) { + maxAmount = num; + } + } + return maxAmount; +} + +function extractCurrency(text: string): string | null { + if (text.includes("$")) return "USD"; + if (text.includes("€")) return "EUR"; + if (text.includes("£")) return "GBP"; + if (text.includes("¥")) return "JPY"; + + const match = text.match(/\b(USD|EUR|GBP|CAD|AUD|JPY)\b/); + return match ? match[1] : null; +} + +function extractDueDate(text: string): string | null { + const match = text.match(DATE_REGEX); + if (!match) return null; + + try { + const parsed = new Date(match[1]); + if (!isNaN(parsed.getTime())) { + return parsed.toISOString().split("T")[0]; + } + } catch { + // ignore + } + return null; +} + +function getDefaultDueDate(sentDate: string): string { + const date = new Date(sentDate); + date.setDate(date.getDate() + 30); + return date.toISOString().split("T")[0]; +} + +export function generateInvoiceId(): string { + return `inv_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`; +} + +export function isOverdue(dueDate: string): boolean { + return new Date(dueDate) < new Date(); +} + +export function daysOverdue(dueDate: string): number { + const due = new Date(dueDate); + const now = new Date(); + if (due >= now) return 0; + const diff = now.getTime() - due.getTime(); + return Math.floor(diff / (1000 * 60 * 60 * 24)); +} + +export function daysSince(date: string): number { + const then = new Date(date); + const now = new Date(); + const diff = now.getTime() - then.getTime(); + return Math.floor(diff / (1000 * 60 * 60 * 24)); +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..b28f6ca --- /dev/null +++ b/src/lib/storage.ts @@ -0,0 +1,67 @@ +import type { Invoice, Settings, Client } from "./types.js"; +import { DEFAULT_SETTINGS } from "./types.js"; + +const STORAGE_KEYS = { + invoices: "invoices", + settings: "settings", + clients: "clients", +}; + +export async function getInvoices(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEYS.invoices); + return (result[STORAGE_KEYS.invoices] as Invoice[]) || []; +} + +export async function saveInvoice(invoice: Invoice): Promise { + const invoices = await getInvoices(); + const existingIndex = invoices.findIndex((i) => i.id === invoice.id); + if (existingIndex >= 0) { + invoices[existingIndex] = invoice; + } else { + invoices.push(invoice); + } + await chrome.storage.local.set({ [STORAGE_KEYS.invoices]: invoices }); +} + +export async function deleteInvoice(id: string): Promise { + const invoices = await getInvoices(); + const filtered = invoices.filter((i) => i.id !== id); + await chrome.storage.local.set({ [STORAGE_KEYS.invoices]: filtered }); +} + +export async function getSettings(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEYS.settings); + return { ...DEFAULT_SETTINGS, ...(result[STORAGE_KEYS.settings] || {}) }; +} + +export async function saveSettings(settings: Settings): Promise { + await chrome.storage.local.set({ [STORAGE_KEYS.settings]: settings }); +} + +export async function getClients(): Promise { + const result = await chrome.storage.local.get(STORAGE_KEYS.clients); + return (result[STORAGE_KEYS.clients] as Client[]) || []; +} + +export async function updateClientsFromInvoices(invoices: Invoice[]): Promise { + const clientMap = new Map(); + for (const inv of invoices) { + const existing = clientMap.get(inv.clientEmail); + if (existing) { + existing.totalInvoiced += inv.amount; + if (inv.status === "paid") existing.totalPaid += inv.amount; + if (inv.status === "overdue") existing.totalOverdue += inv.amount; + existing.invoiceCount += 1; + } else { + clientMap.set(inv.clientEmail, { + email: inv.clientEmail, + name: inv.clientName, + totalInvoiced: inv.amount, + totalPaid: inv.status === "paid" ? inv.amount : 0, + totalOverdue: inv.status === "overdue" ? inv.amount : 0, + invoiceCount: 1, + }); + } + } + await chrome.storage.local.set({ [STORAGE_KEYS.clients]: Array.from(clientMap.values()) }); +} diff --git a/src/lib/stripe-paypal.ts b/src/lib/stripe-paypal.ts new file mode 100644 index 0000000..7c9e377 --- /dev/null +++ b/src/lib/stripe-paypal.ts @@ -0,0 +1,82 @@ +import type { Invoice } from "./types.js"; + +/** + * Checks for payment confirmation emails from Stripe and PayPal + * to automatically update invoice status. Since this is a client-only + * extension without a backend, we scan the user's email for confirmation + * messages from these payment providers. + */ + +const STRIPE_SENDERS = ["receipts@stripe.com", "receipts@stripe.com", "support@stripe.com"]; +const PAYPAL_SENDERS = ["service@paypal.com", "paypal@paypal.com"]; + +export interface PaymentConfirmation { + invoiceId?: string; + amount: number; + currency: string; + payerEmail: string; + paymentDate: string; + provider: "stripe" | "paypal"; +} + +export function scanForPaymentConfirmation( + subject: string, + body: string, + fromEmail: string +): PaymentConfirmation | null { + const isStripe = STRIPE_SENDERS.some((s) => fromEmail.toLowerCase().includes(s.toLowerCase())); + const isPayPal = PAYPAL_SENDERS.some((s) => fromEmail.toLowerCase().includes(s.toLowerCase())); + + if (!isStripe && !isPayPal) return null; + + const provider = isStripe ? "stripe" : "paypal"; + const text = `${subject} ${body}`; + + // Extract amount from confirmation email + const amountMatch = text.match(/[\$€£¥]\s?([\d,]+\.?\d*)/); + const amount = amountMatch ? parseFloat(amountMatch[1].replace(/,/g, "")) : 0; + + // Extract currency + const currency = text.includes("€") ? "EUR" : text.includes("£") ? "GBP" : text.includes("¥") ? "JPY" : "USD"; + + // Extract payer email from body + const emailMatch = body.match(/([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/); + const payerEmail = emailMatch ? emailMatch[1] : ""; + + return { + amount, + currency, + payerEmail, + paymentDate: new Date().toISOString(), + provider, + }; +} + +export function matchConfirmationToInvoice( + confirmation: PaymentConfirmation, + invoices: Invoice[] +): Invoice | null { + // Match by payer email and amount (within tolerance) + const matches = invoices.filter((inv) => + inv.clientEmail.toLowerCase() === confirmation.payerEmail.toLowerCase() && + Math.abs(inv.amount - confirmation.amount) < 0.01 && + inv.currency === confirmation.currency && + inv.status !== "paid" + ); + + // Return the most recent match + if (matches.length === 0) return null; + return matches.sort((a, b) => + new Date(b.sentDate).getTime() - new Date(a.sentDate).getTime() + )[0]; +} + +export function markInvoicePaid(invoice: Invoice, confirmation: PaymentConfirmation): Invoice { + return { + ...invoice, + status: "paid", + paymentDate: confirmation.paymentDate, + paymentMethod: confirmation.provider, + updatedAt: new Date().toISOString(), + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..ecc2fa7 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,61 @@ +export type PaymentStatus = "pending" | "paid" | "overdue" | "cancelled"; + +export interface Invoice { + id: string; + clientEmail: string; + clientName: string; + subject: string; + amount: number; + currency: string; + dueDate: string; // ISO date + sentDate: string; // ISO date + status: PaymentStatus; + source: "gmail" | "outlook" | "manual"; + sourceThreadId?: string; + lastFollowUpDate?: string; + followUpCount: number; + followUpSchedule: number[]; // days after sent: default [7, 14, 30] + notes: string; + paymentMethod?: "stripe" | "paypal" | "bank" | "other"; + paymentDate?: string; + createdAt: string; + updatedAt: string; +} + +export interface Client { + email: string; + name: string; + totalInvoiced: number; + totalPaid: number; + totalOverdue: number; + invoiceCount: number; +} + +export interface DashboardStats { + totalOutstanding: number; + totalPaid: number; + totalOverdue: number; + overdueCount: number; + pendingCount: number; + paidCount: number; +} + +export interface Settings { + followUpDays: number[]; + defaultCurrency: string; + enableAutoDetect: boolean; + enableFollowUpGeneration: boolean; + userName: string; + userEmail: string; + paypalEmail?: string; + stripeAccountId?: string; +} + +export const DEFAULT_SETTINGS: Settings = { + followUpDays: [7, 14, 30], + defaultCurrency: "USD", + enableAutoDetect: true, + enableFollowUpGeneration: true, + userName: "", + userEmail: "", +}; diff --git a/src/manifest.json b/src/manifest.json new file mode 100644 index 0000000..4b359db --- /dev/null +++ b/src/manifest.json @@ -0,0 +1,46 @@ +{ + "manifest_version": 3, + "name": "InvoiceChaser", + "version": "1.0.0", + "description": "Freelancer Payment Tracker & Auto Follow-Up", + "permissions": ["storage", "alarms", "activeTab", "scripting"], + "host_permissions": [ + "https://mail.google.com/*", + "https://outlook.live.com/*", + "https://outlook.office.com/*", + "https://outlook.office365.com/*" + ], + "background": { + "service_worker": "src/background.ts", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://mail.google.com/*"], + "js": ["src/content/gmail.ts"], + "run_at": "document_idle" + }, + { + "matches": [ + "https://outlook.live.com/*", + "https://outlook.office.com/*", + "https://outlook.office365.com/*" + ], + "js": ["src/content/outlook.ts"], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..774333e --- /dev/null +++ b/src/popup/popup.css @@ -0,0 +1,378 @@ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + font-size: 13px; + color: #1f2937; + background: #f9fafb; + width: 380px; + min-height: 500px; +} + +#app { + padding: 0; +} + +header { + background: #fff; + border-bottom: 1px solid #e5e7eb; + padding: 12px 16px; + position: sticky; + top: 0; + z-index: 10; +} + +h1 { + font-size: 16px; + font-weight: 700; + color: #111827; + margin-bottom: 10px; +} + +nav { + display: flex; + gap: 4px; +} + +nav button { + flex: 1; + padding: 6px 8px; + border: 1px solid #d1d5db; + background: #fff; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + color: #4b5563; + transition: all 0.15s; +} + +nav button:hover { + background: #f3f4f6; +} + +nav button.active { + background: #22c55e; + color: #fff; + border-color: #22c55e; +} + +main { + padding: 12px 16px 20px; +} + +.tab { + display: none; +} + +.tab.active { + display: block; +} + +/* Stats */ +.stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 16px; +} + +.stat-card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 12px; + text-align: center; +} + +.stat-card.pending { + border-left: 3px solid #f59e0b; +} + +.stat-card.paid { + border-left: 3px solid #22c55e; +} + +.stat-card.overdue { + border-left: 3px solid #ef4444; +} + +.stat-value { + display: block; + font-size: 18px; + font-weight: 700; + color: #111827; +} + +.stat-label { + display: block; + font-size: 11px; + color: #6b7280; + margin-top: 2px; +} + +/* Follow-ups */ +.follow-ups h2 { + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; + color: #374151; +} + +.follow-up-card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; +} + +.follow-up-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.follow-up-meta { + font-size: 11px; + color: #6b7280; + margin-bottom: 6px; +} + +.follow-up-body { + background: #f3f4f6; + border-radius: 6px; + padding: 8px; + font-size: 12px; + margin-bottom: 8px; +} + +.follow-up-body pre { + white-space: pre-wrap; + word-break: break-word; + font-family: inherit; + margin-top: 4px; + font-size: 11px; + color: #374151; +} + +.follow-up-actions { + display: flex; + gap: 6px; +} + +.follow-up-actions button { + flex: 1; + padding: 5px 8px; + border: 1px solid #d1d5db; + background: #fff; + border-radius: 4px; + font-size: 11px; + cursor: pointer; +} + +.follow-up-actions button:hover { + background: #f3f4f6; +} + +.badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.badge.polite { + background: #dbeafe; + color: #1e40af; +} + +.badge.firm { + background: #fef3c7; + color: #92400e; +} + +.badge.urgent { + background: #fee2e2; + color: #991b1b; +} + +/* Invoices */ +.filter-bar { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.filter-bar select, +.filter-bar input { + flex: 1; + padding: 6px 8px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 12px; + background: #fff; +} + +.invoice-card { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; +} + +.invoice-card.pending { + border-left: 3px solid #f59e0b; +} + +.invoice-card.paid { + border-left: 3px solid #22c55e; +} + +.invoice-card.overdue { + border-left: 3px solid #ef4444; +} + +.invoice-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.invoice-subject { + font-size: 12px; + color: #374151; + margin-bottom: 4px; +} + +.invoice-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: #6b7280; +} + +.status-badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.status-badge.pending { + background: #fef3c7; + color: #92400e; +} + +.status-badge.paid { + background: #d1fae5; + color: #065f46; +} + +.status-badge.overdue { + background: #fee2e2; + color: #991b1b; +} + +.overdue-badge { + background: #ef4444; + color: #fff; + padding: 1px 4px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; +} + +.invoice-actions { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.invoice-actions button { + padding: 4px 8px; + border: 1px solid #d1d5db; + background: #fff; + border-radius: 4px; + font-size: 11px; + cursor: pointer; +} + +.invoice-actions button:hover { + background: #f3f4f6; +} + +/* Forms */ +form label { + display: block; + font-size: 11px; + font-weight: 600; + color: #374151; + margin: 8px 0 4px; +} + +form input, +form select, +form textarea { + width: 100%; + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + font-size: 13px; + font-family: inherit; +} + +form input:focus, +form select:focus, +form textarea:focus { + outline: none; + border-color: #22c55e; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.1); +} + +form button[type="submit"] { + width: 100%; + padding: 10px; + margin-top: 12px; + border: none; + background: #22c55e; + color: #fff; + border-radius: 6px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +form button[type="submit"]:hover { + background: #16a34a; +} + +.checkbox-label { + display: flex; + align-items: center; + gap: 6px; + font-weight: 400; + cursor: pointer; +} + +.checkbox-label input { + width: auto; +} + +/* Empty state */ +.empty { + text-align: center; + padding: 24px; + color: #9ca3af; + font-size: 13px; +} diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..10854c5 --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,139 @@ + + + + + + InvoiceChaser + + + +
+
+

InvoiceChaser

+ +
+ +
+ +
+
+
+ $0 + Pending +
+ +
+ $0 + Overdue +
+
+ 0 + Invoices +
+
+ + +
+ + +
+
+ + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + +
+
+
+
+ + + + diff --git a/src/popup/popup.ts b/src/popup/popup.ts new file mode 100644 index 0000000..b97ef4e --- /dev/null +++ b/src/popup/popup.ts @@ -0,0 +1,300 @@ +import type { Invoice, PaymentStatus, Settings } from "../lib/types.js"; +import { getInvoices, saveInvoice, deleteInvoice, getSettings, saveSettings, updateClientsFromInvoices } from "../lib/storage.js"; +import { isOverdue, generateInvoiceId, daysSince } from "../lib/invoice-detector.js"; +import { generateFollowUp, shouldFollowUp } from "../lib/follow-up.js"; + +// Tab switching +const tabButtons = document.querySelectorAll("nav button[data-tab]"); +const tabSections = document.querySelectorAll(".tab"); + +tabButtons.forEach((btn) => { + btn.addEventListener("click", () => { + const tab = btn.getAttribute("data-tab"); + if (!tab) return; + tabButtons.forEach((b) => b.classList.remove("active")); + tabSections.forEach((s) => s.classList.remove("active")); + btn.classList.add("active"); + document.getElementById(`tab-${tab}`)?.classList.add("active"); + if (tab === "dashboard" || tab === "invoices") { + loadData(); + } + }); +}); + +// Load all data +async function loadData() { + const invoices = await getInvoices(); + await updateClientsFromInvoices(invoices); + renderDashboard(invoices); + renderInvoices(invoices); + renderFollowUps(invoices); +} + +// Dashboard rendering +function renderDashboard(invoices: Invoice[]) { + let totalPending = 0; + let totalPaid = 0; + let totalOverdue = 0; + let pendingCount = 0; + let paidCount = 0; + let overdueCount = 0; + + for (const inv of invoices) { + if (inv.status === "pending") { + totalPending += inv.amount; + pendingCount++; + } else if (inv.status === "paid") { + totalPaid += inv.amount; + paidCount++; + } else if (inv.status === "overdue") { + totalOverdue += inv.amount; + overdueCount++; + } + } + + document.getElementById("stat-pending")!.textContent = formatCurrency(totalPending, "USD"); + document.getElementById("stat-paid")!.textContent = formatCurrency(totalPaid, "USD"); + document.getElementById("stat-overdue")!.textContent = formatCurrency(totalOverdue, "USD"); + document.getElementById("stat-count")!.textContent = String(invoices.length); +} + +// Follow-ups rendering +async function renderFollowUps(invoices: Invoice[]) { + const settings = await getSettings(); + const container = document.getElementById("follow-ups-list")!; + const needed = invoices.filter((inv) => shouldFollowUp(inv)); + + if (needed.length === 0) { + container.innerHTML = `

No follow-ups needed right now 🎉

`; + return; + } + + container.innerHTML = needed + .map((inv) => { + const followUp = generateFollowUp(inv, settings); + if (!followUp) return ""; + const days = daysSince(inv.sentDate); + return ` + + `; + }) + .join(""); + + container.querySelectorAll(".copy-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { + const id = (e.target as HTMLElement).getAttribute("data-id")!; + const inv = invoices.find((i) => i.id === id); + if (!inv) return; + const followUp = generateFollowUp(inv, settings); + if (followUp) { + await navigator.clipboard.writeText(followUp.body); + (e.target as HTMLElement).textContent = "Copied!"; + setTimeout(() => ((e.target as HTMLElement).textContent = "Copy to clipboard"), 2000); + } + }); + }); + + container.querySelectorAll(".mark-sent-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { + const id = (e.target as HTMLElement).getAttribute("data-id")!; + await chrome.runtime.sendMessage({ type: "mark-sent-follow-up", data: { invoiceId: id } }); + loadData(); + }); + }); +} + +// Invoices list rendering +function renderInvoices(invoices: Invoice[]) { + const container = document.getElementById("invoices-list")!; + const filterStatus = (document.getElementById("filter-status") as HTMLSelectElement).value; + const search = (document.getElementById("search") as HTMLInputElement).value.toLowerCase(); + + let filtered = invoices; + if (filterStatus !== "all") { + filtered = filtered.filter((inv) => inv.status === filterStatus); + } + if (search) { + filtered = filtered.filter( + (inv) => + inv.clientName.toLowerCase().includes(search) || inv.subject.toLowerCase().includes(search) + ); + } + + if (filtered.length === 0) { + container.innerHTML = `

No invoices found.

`; + return; + } + + container.innerHTML = filtered + .sort((a, b) => new Date(b.sentDate).getTime() - new Date(a.sentDate).getTime()) + .map((inv) => { + const statusClass = inv.status; + const daysOver = isOverdue(inv.dueDate) ? daysSince(inv.dueDate) : 0; + const overdueBadge = inv.status === "overdue" ? `${daysOver}d overdue` : ""; + return ` +
+
+
+ ${escapeHtml(inv.clientName)} + ${inv.status} +
+
${escapeHtml(inv.subject)}
+
+ ${formatCurrency(inv.amount, inv.currency)} + Due: ${formatDate(inv.dueDate)} + Sent: ${formatDate(inv.sentDate)} + ${overdueBadge} +
+
+
+ ${inv.status !== "paid" ? `` : ""} + +
+
+ `; + }) + .join(""); + + container.querySelectorAll(".mark-paid").forEach((btn) => { + btn.addEventListener("click", async (e) => { + const id = (e.target as HTMLElement).getAttribute("data-id")!; + const allInvoices = await getInvoices(); + const inv = allInvoices.find((i) => i.id === id); + if (!inv) return; + const updated: Invoice = { + ...inv, + status: "paid", + paymentDate: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + await saveInvoice(updated); + loadData(); + }); + }); + + container.querySelectorAll(".delete-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { + const id = (e.target as HTMLElement).getAttribute("data-id")!; + if (confirm("Delete this invoice?")) { + await deleteInvoice(id); + loadData(); + } + }); + }); +} + +// Filter and search listeners +document.getElementById("filter-status")?.addEventListener("change", () => loadData()); +document.getElementById("search")?.addEventListener("input", () => loadData()); + +// Add invoice form +const addForm = document.getElementById("add-invoice-form") as HTMLFormElement; +// Set default dates +document.getElementById("add-sent-date")!.setAttribute("value", new Date().toISOString().split("T")[0]); +document.getElementById("add-due-date")!.setAttribute("value", getDefaultDueDate()); + +addForm?.addEventListener("submit", async (e) => { + e.preventDefault(); + const settings = await getSettings(); + const invoice: Invoice = { + id: generateInvoiceId(), + clientName: (document.getElementById("add-client-name") as HTMLInputElement).value, + clientEmail: (document.getElementById("add-client-email") as HTMLInputElement).value, + subject: (document.getElementById("add-subject") as HTMLInputElement).value, + amount: parseFloat((document.getElementById("add-amount") as HTMLInputElement).value), + currency: (document.getElementById("add-currency") as HTMLSelectElement).value, + dueDate: (document.getElementById("add-due-date") as HTMLInputElement).value, + sentDate: (document.getElementById("add-sent-date") as HTMLInputElement).value, + status: "pending", + source: "manual", + followUpCount: 0, + followUpSchedule: settings.followUpDays, + notes: (document.getElementById("add-notes") as HTMLTextAreaElement).value, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + await saveInvoice(invoice); + addForm.reset(); + document.getElementById("add-sent-date")!.setAttribute("value", new Date().toISOString().split("T")[0]); + document.getElementById("add-due-date")!.setAttribute("value", getDefaultDueDate()); + alert("Invoice added!"); + // Switch to dashboard + tabButtons.forEach((b) => b.classList.remove("active")); + tabSections.forEach((s) => s.classList.remove("active")); + document.querySelector('[data-tab="dashboard"]')?.classList.add("active"); + document.getElementById("tab-dashboard")?.classList.add("active"); + loadData(); +}); + +// Settings form +const settingsForm = document.getElementById("settings-form") as HTMLFormElement; +settingsForm?.addEventListener("submit", async (e) => { + e.preventDefault(); + const daysStr = (document.getElementById("set-follow-up-days") as HTMLInputElement).value; + const days = daysStr + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !isNaN(n) && n > 0); + + const settings: Settings = { + userName: (document.getElementById("set-user-name") as HTMLInputElement).value, + userEmail: (document.getElementById("set-user-email") as HTMLInputElement).value, + defaultCurrency: (document.getElementById("set-currency") as HTMLSelectElement).value, + followUpDays: days.length > 0 ? days : [7, 14, 30], + enableAutoDetect: (document.getElementById("set-auto-detect") as HTMLInputElement).checked, + enableFollowUpGeneration: (document.getElementById("set-follow-up-gen") as HTMLInputElement).checked, + }; + + await saveSettings(settings); + alert("Settings saved!"); +}); + +async function loadSettings() { + const settings = await getSettings(); + (document.getElementById("set-user-name") as HTMLInputElement).value = settings.userName; + (document.getElementById("set-user-email") as HTMLInputElement).value = settings.userEmail; + (document.getElementById("set-currency") as HTMLSelectElement).value = settings.defaultCurrency; + (document.getElementById("set-follow-up-days") as HTMLInputElement).value = settings.followUpDays.join(","); + (document.getElementById("set-auto-detect") as HTMLInputElement).checked = settings.enableAutoDetect; + (document.getElementById("set-follow-up-gen") as HTMLInputElement).checked = settings.enableFollowUpGeneration; +} + +// Helpers +function formatCurrency(amount: number, currency: string): string { + return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount); +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +function getDefaultDueDate(): string { + const d = new Date(); + d.setDate(d.getDate() + 30); + return d.toISOString().split("T")[0]; +} + +function escapeHtml(str: string): string { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; +} + +// Init +loadSettings(); +loadData(); diff --git a/src/test/invoice-detector.test.ts b/src/test/invoice-detector.test.ts new file mode 100644 index 0000000..2b91646 --- /dev/null +++ b/src/test/invoice-detector.test.ts @@ -0,0 +1,101 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + detectInvoiceFromEmail, + isOverdue, + daysOverdue, + generateInvoiceId, +} from "../lib/invoice-detector.js"; + +describe("invoice-detector", () => { + describe("detectInvoiceFromEmail", () => { + it("detects invoice from subject", () => { + const result = detectInvoiceFromEmail( + "Invoice #123 for $500 from Acme Corp", + "Please pay by 12/31/2024", + "billing@acme.com", + "Acme Corp", + "2024-12-01", + "thread-1" + ); + assert.ok(result); + assert.strictEqual(result!.clientEmail, "billing@acme.com"); + assert.strictEqual(result!.clientName, "Acme Corp"); + assert.strictEqual(result!.amount, 500); + assert.strictEqual(result!.currency, "USD"); + }); + + it("returns null for non-invoice email", () => { + const result = detectInvoiceFromEmail( + "Meeting notes from today", + "Here are the notes...", + "alice@example.com", + "Alice", + "2024-12-01", + "thread-2" + ); + assert.strictEqual(result, null); + }); + + it("detects payment confirmation", () => { + const result = detectInvoiceFromEmail( + "Payment received - thank you!", + "We have received your payment.", + "receipts@stripe.com", + "Stripe", + "2024-12-01", + "thread-3" + ); + assert.ok(result); + assert.strictEqual(result!.isPaidConfirmation, true); + }); + + it("extracts EUR currency", () => { + const result = detectInvoiceFromEmail( + "Invoice for €1,250.00", + "Payment due", + "vendor@example.com", + "Vendor", + "2024-12-01", + "thread-4" + ); + assert.ok(result); + assert.strictEqual(result!.currency, "EUR"); + assert.strictEqual(result!.amount, 1250); + }); + }); + + describe("isOverdue", () => { + it("returns true for past date", () => { + assert.strictEqual(isOverdue("2024-01-01"), true); + }); + + it("returns false for future date", () => { + const future = new Date(); + future.setFullYear(future.getFullYear() + 1); + assert.strictEqual(isOverdue(future.toISOString().split("T")[0]), false); + }); + }); + + describe("daysOverdue", () => { + it("returns positive days for past due date", () => { + const days = daysOverdue("2020-01-01"); + assert.ok(days > 1000); + }); + + it("returns 0 for future due date", () => { + const future = new Date(); + future.setDate(future.getDate() + 10); + assert.strictEqual(daysOverdue(future.toISOString().split("T")[0]), 0); + }); + }); + + describe("generateInvoiceId", () => { + it("generates unique IDs", () => { + const id1 = generateInvoiceId(); + const id2 = generateInvoiceId(); + assert.notStrictEqual(id1, id2); + assert.ok(id1.startsWith("inv_")); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bf01522 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "outDir": "dist", + "rootDir": "src", + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "types": ["chrome", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e50d6cf --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,16 @@ +import { crx } from "@crxjs/vite-plugin"; +import { defineConfig } from "vite"; +import manifest from "./src/manifest.json" with { type: "json" }; + +export default defineConfig({ + build: { + outDir: "dist", + emptyOutDir: false, + rollupOptions: { + input: { + popup: "src/popup/popup.html", + }, + }, + }, + plugins: [crx({ manifest })], +});