From 510746e0eafa9de0af30a8b636fd916723a59438 Mon Sep 17 00:00:00 2001 From: Fanfulla <224395883+Fanfulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:59:44 +0200 Subject: [PATCH 1/8] chore: remove dead code and unused dependencies, move test fixtures Drops unused components/helpers and the broken browser-fs-access fallback (directoryOpen returns File[], not a handle), removes jszip/adm-zip/ better-sqlite3, simplifies .gitignore and moves private real-backup fixtures to ./fixtures (overridable via KOBUP_FIXTURES_DIR). Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 248 +------------ package-lock.json | 549 +---------------------------- package.json | 4 - src/components/backup/BookCard.jsx | 106 ------ src/components/backup/BookList.jsx | 226 ------------ src/stores/koboStore.js | 17 +- src/utils/fileSystem.js | 96 +---- src/utils/koboDatabase.js | 31 -- src/utils/realBackup.test.js | 8 +- src/utils/restore.js | 35 -- src/utils/validation.js | 79 ----- 11 files changed, 36 insertions(+), 1363 deletions(-) delete mode 100644 src/components/backup/BookCard.jsx delete mode 100644 src/components/backup/BookList.jsx diff --git a/.gitignore b/.gitignore index 19553c7..1583903 100644 --- a/.gitignore +++ b/.gitignore @@ -1,261 +1,49 @@ -# .gitignore for Kobo Backup Manager -# React + Vite + JavaScript project - # Dependencies node_modules/ -/.pnp -.pnp.js +.pnpm-store/ # Build outputs dist/ dist-ssr/ -build/ *.local +*.tsbuildinfo +vite.config.*.timestamp-* -# Logs -logs/ +# Logs & caches *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* +.eslintcache +.cache/ +coverage/ -# Editor directories and files +# Editors / OS .vscode/* !.vscode/extensions.json !.vscode/settings.json .idea/ .DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? -*.swp -*.swo -*~ - -# IDE - VSCode -.vscode/ -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -.history/ - -# IDE - IntelliJ / WebStorm -.idea/ -*.iml -*.iws -out/ - -# OS files -.DS_Store -.DS_Store? -._* -.Spotlight-V100 -.Trashes -ehthumbs.db Thumbs.db desktop.ini -*.zip +nul -# Environment variables +# Environment .env -.env.local -.env.development.local -.env.test.local -.env.production.local -.env*.local +.env.* +!.env.example -# Testing -coverage/ -.nyc_output/ -*.lcov -.coverage -htmlcov/ -test-results/ -playwright-report/ - -# Temporary files -tmp/ -temp/ -*.tmp -*.temp -.cache/ - -# Package manager files -package-lock.json.backup -yarn.lock.backup -.pnpm-store/ -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -# Vite specific -vite.config.js.timestamp-* -vite.config.ts.timestamp-* - -# Build artifacts -*.tsbuildinfo -.eslintcache - -# Database files (for local testing) +# Private test fixtures (real Kobo databases / backups contain personal data) +fixtures/ +*.zip *.sqlite *.sqlite3 *.db *.db-shm *.db-wal +*.sqlite-shm +*.sqlite-wal -# Backup files -*.bak -*.backup -*~ - -# Runtime data -pids/ -*.pid -*.seed -*.pid.lock - -# TypeScript cache -*.tsbuildinfo - -# Optional npm cache directory -.npm - -# Optional eslint cache -.eslintcache - -# Optional stylelint cache -.stylelintcache - -# Microbundle cache -.rpt2_cache/ -.rts2_cache_cjs/ -.rts2_cache_es/ -.rts2_cache_umd/ - -# Optional REPL history -.node_repl_history - -# Output of 'npm pack' -*.tgz - -# Serverless directories -.serverless/ - -# FuseBox cache -.fusebox/ - -# DynamoDB Local files -.dynamodb/ - -# TernJS port file -.tern-port - -# Stores VSCode versions used for testing VSCode extensions -.vscode-test - -# yarn v2 -.yarn/cache -.yarn/unplugged -.yarn/build-state.yml -.yarn/install-state.gz -.pnp.* - -# Analytics and tracking (if using local config files) -analytics-config.json -tracking-config.json - -# User-specific data (backup history stored in browser, but ignore if exported) -backups/ -backup-exports/ -kobo-backups/ - -# Documentation build -docs/_build/ -docs/.doctrees/ - -# MacOS -.AppleDouble -.LSOverride -Icon -.DocumentRevisions-V100 -.fseventsd -.TemporaryItems -.VolumeIcon.icns -.com.apple.timemachine.donotpresent -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -# Windows -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db -*.stackdump -[Dd]esktop.ini -$RECYCLE.BIN/ -*.cab -*.msi -*.msix -*.msm -*.msp -*.lnk - -# Linux -.fuse_hidden* -.directory -.Trash-* -.nfs* - -# Python (if using Python scripts for development) -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -venv/ -ENV/ -env/ -.venv - -# Certificates and keys -*.pem -*.key -*.crt -*.p12 -*.pfx - -# Local development -.local/ -local/ -scratch/ - -# Ignore nul file (appears to be an error file) -nul - -# Exclude .claude and .planning (local dev workflow files, not needed on GitHub) +# Local dev workflow files (not published) .claude/ .planning/ - -# Keep public directory for static assets -!public/ - -# Keep only README and LICENSE -!README.md -!LICENSE CONTRIBUTING.md CODE_OF_CONDUCT.md KOBO_BACKUP_WEBAPP_BLUEPRINT.md -test-backup.mjs - -# Local analysis/testing folders -_backup_analysis/ diff --git a/package-lock.json b/package-lock.json index 6155608..d7fceff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,7 @@ "dependencies": { "@vercel/analytics": "^2.0.1", "@zip.js/zip.js": "^2.8.26", - "browser-fs-access": "^0.35.0", "client-zip": "^2.5.0", - "jszip": "^3.10.1", "react": "^18.3.1", "react-dom": "^18.3.1", "sql.js": "^1.10.3", @@ -24,9 +22,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", - "adm-zip": "^0.5.16", "autoprefixer": "^10.4.19", - "better-sqlite3": "^12.6.2", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.2", "eslint-plugin-react-hooks": "^4.6.2", @@ -1719,16 +1715,6 @@ "node": ">=0.4.0" } }, - "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2035,27 +2021,6 @@ "dev": true, "license": "MIT" }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/baseline-browser-mapping": { "version": "2.9.11", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", @@ -2066,21 +2031,6 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -2094,43 +2044,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/bl/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2155,12 +2068,6 @@ "node": ">=8" } }, - "node_modules/browser-fs-access": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/browser-fs-access/-/browser-fs-access-0.35.0.tgz", - "integrity": "sha512-sLoadumpRfsjprP8XzVjpQc0jK8yqHBx0PtUTGYj2fftT+P/t+uyDAQdMgGAPKD011in/O+YYGh7fIs0oG/viw==", - "license": "Apache-2.0" - }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2195,31 +2102,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -2408,13 +2290,6 @@ "node": ">= 6" } }, - "node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC" - }, "node_modules/client-zip": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/client-zip/-/client-zip-2.5.0.tgz", @@ -2472,12 +2347,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2592,22 +2461,6 @@ } } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-eql": { "version": "4.1.4", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", @@ -2621,16 +2474,6 @@ "node": ">=6" } }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2684,16 +2527,6 @@ "node": ">=6" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", @@ -2761,16 +2594,6 @@ "dev": true, "license": "ISC" }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/es-abstract": { "version": "1.24.1", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", @@ -3264,16 +3087,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "engines": { - "node": ">=6" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -3348,13 +3161,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3437,13 +3243,6 @@ "url": "https://github.com/sponsors/rawify" } }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", - "dev": true, - "license": "MIT" - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3607,13 +3406,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT" - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3806,27 +3598,6 @@ "node": ">=16.17.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3837,12 +3608,6 @@ "node": ">= 4" } }, - "node_modules/immediate": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", - "license": "MIT" - }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -3896,12 +3661,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "dev": true, "license": "ISC" }, @@ -4348,12 +4107,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4471,18 +4224,6 @@ "node": ">=4.0" } }, - "node_modules/jszip": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", - "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", - "license": "(MIT OR GPL-3.0-or-later)", - "dependencies": { - "lie": "~3.3.0", - "pako": "~1.0.2", - "readable-stream": "~2.3.6", - "setimmediate": "^1.0.5" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -4507,15 +4248,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lie": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", - "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", - "license": "MIT", - "dependencies": { - "immediate": "~3.0.5" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4683,19 +4415,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -4719,23 +4438,6 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT" - }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", @@ -4794,13 +4496,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT" - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -4808,32 +4503,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-abi": { - "version": "3.87.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", - "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", @@ -5092,12 +4761,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pako": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", - "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5418,33 +5081,6 @@ "dev": true, "license": "MIT" }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5501,12 +5137,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5526,17 +5156,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pump": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", - "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5568,32 +5187,6 @@ ], "license": "MIT" }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -5647,21 +5240,6 @@ "pify": "^2.3.0" } }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -5882,12 +5460,6 @@ "dev": true, "license": "MIT" }, - "node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -5998,12 +5570,6 @@ "node": ">= 0.4" } }, - "node_modules/setimmediate": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -6123,53 +5689,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.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", @@ -6214,15 +5733,6 @@ "node": ">= 0.4" } }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -6501,51 +6011,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tar-stream/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -6671,19 +6136,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6878,6 +6330,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, "license": "MIT" }, "node_modules/vite": { diff --git a/package.json b/package.json index 89f2e82..d080c1f 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,7 @@ "dependencies": { "@vercel/analytics": "^2.0.1", "@zip.js/zip.js": "^2.8.26", - "browser-fs-access": "^0.35.0", "client-zip": "^2.5.0", - "jszip": "^3.10.1", "react": "^18.3.1", "react-dom": "^18.3.1", "sql.js": "^1.10.3", @@ -28,9 +26,7 @@ "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", - "adm-zip": "^0.5.16", "autoprefixer": "^10.4.19", - "better-sqlite3": "^12.6.2", "eslint": "^8.57.0", "eslint-plugin-react": "^7.34.2", "eslint-plugin-react-hooks": "^4.6.2", diff --git a/src/components/backup/BookCard.jsx b/src/components/backup/BookCard.jsx deleted file mode 100644 index 80f451c..0000000 --- a/src/components/backup/BookCard.jsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Book card - displays individual book with cover, title, author, and progress - */ - -import PropTypes from 'prop-types'; -import { Icon } from '../common/Icon.jsx'; -import { ProgressBar } from '../common/ProgressBar.jsx'; - -export function BookCard({ book, variant = 'default' }) { - const { title, author, progress = 0, coverUrl } = book; - - if (variant === 'compact') { - return ( -
- {/* Book Cover */} -
- {coverUrl ? ( - {title} - ) : ( - - )} -
- - {/* Book Info */} -
-

- {title} -

-

{author}

- - {/* Progress */} - {progress > 0 && ( -
- - - {Math.round(progress)}% - -
- )} -
-
- ); - } - - return ( -
- {/* Book Cover */} -
- {coverUrl ? ( - {title} - ) : ( - - )} -
- - {/* Book Info */} -
-

- {title} -

-

{author}

- - {/* Progress Bar */} - {progress > 0 && ( -
-
- Reading Progress - - {Math.round(progress)}% - -
- -
- )} - - {progress === 0 && ( -
- - Not started -
- )} - - {progress === 100 && ( -
- - Finished -
- )} -
-
- ); -} - -BookCard.propTypes = { - book: PropTypes.shape({ - title: PropTypes.string.isRequired, - author: PropTypes.string, - progress: PropTypes.number, - coverUrl: PropTypes.string, - }).isRequired, - variant: PropTypes.oneOf(['default', 'compact']), -}; diff --git a/src/components/backup/BookList.jsx b/src/components/backup/BookList.jsx deleted file mode 100644 index 34ca83f..0000000 --- a/src/components/backup/BookList.jsx +++ /dev/null @@ -1,226 +0,0 @@ -/** - * Book list - displays grid of books with search and filter - */ - -import { useState, useMemo } from 'react'; -import PropTypes from 'prop-types'; -import { BookCard } from './BookCard.jsx'; -import { Icon } from '../common/Icon.jsx'; -import { Button } from '../common/Button.jsx'; - -export function BookList({ books, maxDisplay = 6, showSearch = false }) { - const [searchQuery, setSearchQuery] = useState(''); - const [filterStatus, setFilterStatus] = useState('all'); // all, reading, finished, unread - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 12; - - // Filter and search books - const filteredBooks = useMemo(() => { - let filtered = [...books]; - - // Apply search filter - if (searchQuery) { - const query = searchQuery.toLowerCase(); - filtered = filtered.filter( - (book) => - book.title.toLowerCase().includes(query) || - (book.author && book.author.toLowerCase().includes(query)) - ); - } - - // Apply status filter - if (filterStatus === 'reading') { - filtered = filtered.filter((book) => book.progress > 0 && book.progress < 100); - } else if (filterStatus === 'finished') { - filtered = filtered.filter((book) => book.progress === 100); - } else if (filterStatus === 'unread') { - filtered = filtered.filter((book) => !book.progress || book.progress === 0); - } - - return filtered; - }, [books, searchQuery, filterStatus]); - - // Paginate books - const totalPages = Math.ceil(filteredBooks.length / itemsPerPage); - const paginatedBooks = filteredBooks.slice( - (currentPage - 1) * itemsPerPage, - currentPage * itemsPerPage - ); - - // For limited display (e.g., in preview) - const displayBooks = maxDisplay ? filteredBooks.slice(0, maxDisplay) : paginatedBooks; - - if (books.length === 0) { - return ( -
-
- -
-

No books found

-
- ); - } - - return ( -
- {/* Search and Filter */} - {showSearch && ( -
- {/* Search Input */} -
- { - setSearchQuery(e.target.value); - setCurrentPage(1); // Reset to first page on search - }} - className="w-full px-4 py-3 pl-10 bg-white border-2 border-kobo-cream-dark rounded-lg focus:outline-none focus:border-kobo-accent transition-colors" - /> - -
- - {/* Filter Buttons */} -
- { - setFilterStatus('all'); - setCurrentPage(1); - }} - > - All ({books.length}) - - { - setFilterStatus('reading'); - setCurrentPage(1); - }} - > - Reading ({books.filter((b) => b.progress > 0 && b.progress < 100).length}) - - { - setFilterStatus('finished'); - setCurrentPage(1); - }} - > - Finished ({books.filter((b) => b.progress === 100).length}) - - { - setFilterStatus('unread'); - setCurrentPage(1); - }} - > - Unread ({books.filter((b) => !b.progress || b.progress === 0).length}) - -
-
- )} - - {/* Results Count */} - {filteredBooks.length > 0 && ( -

- Showing {displayBooks.length} of {filteredBooks.length} book - {filteredBooks.length !== 1 ? 's' : ''} -

- )} - - {/* Books Grid */} - {filteredBooks.length === 0 ? ( -
-

No books match your search

-
- ) : ( -
- {displayBooks.map((book, index) => ( - - ))} -
- )} - - {/* Pagination */} - {!maxDisplay && totalPages > 1 && ( -
- - -
- {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => ( - - ))} -
- - -
- )} -
- ); -} - -function FilterButton({ active, onClick, children }) { - return ( - - ); -} - -FilterButton.propTypes = { - active: PropTypes.bool, - onClick: PropTypes.func.isRequired, - children: PropTypes.node.isRequired, -}; - -BookList.propTypes = { - books: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - title: PropTypes.string.isRequired, - author: PropTypes.string, - progress: PropTypes.number, - coverUrl: PropTypes.string, - }) - ).isRequired, - maxDisplay: PropTypes.number, - showSearch: PropTypes.bool, -}; diff --git a/src/stores/koboStore.js b/src/stores/koboStore.js index a99a29f..c0f6db1 100644 --- a/src/stores/koboStore.js +++ b/src/stores/koboStore.js @@ -18,15 +18,6 @@ export const useKoboStore = create( // Backup history (persisted to localStorage) backups: [], - // User settings - settings: { - theme: 'light', - language: 'en', - includeAnnotationsByDefault: true, - includeProgressByDefault: true, - includeSettingsByDefault: false, - }, - // UI state currentPage: 'home', @@ -57,11 +48,6 @@ export const useKoboStore = create( backups: state.backups.filter((b) => b.id !== id), })), - updateSettings: (newSettings) => - set((state) => ({ - settings: { ...state.settings, ...newSettings }, - })), - setCurrentPage: (page) => set({ currentPage: page }), clearDevice: () => @@ -85,10 +71,9 @@ export const useKoboStore = create( }), { name: 'kobo-backup-storage', - // Only persist backups and settings, not device data + // Only persist backups, not device data partialize: (state) => ({ backups: state.backups, - settings: state.settings, }), } ) diff --git a/src/utils/fileSystem.js b/src/utils/fileSystem.js index 3c78616..18aa3f3 100644 --- a/src/utils/fileSystem.js +++ b/src/utils/fileSystem.js @@ -3,7 +3,6 @@ * Wraps browser File System Access API with fallbacks */ -import { directoryOpen, fileSave } from 'browser-fs-access'; import { FileSystemError, ERROR_CODES } from './errors.js'; /** @@ -12,23 +11,18 @@ import { FileSystemError, ERROR_CODES } from './errors.js'; * @returns {Promise} Directory handle */ export async function selectDirectory(options = {}) { + if (!('showDirectoryPicker' in window)) { + throw new FileSystemError( + 'This browser does not support the File System Access API', + ERROR_CODES.FS_NOT_SUPPORTED + ); + } try { - if ('showDirectoryPicker' in window) { - // Modern File System Access API - const dirHandle = await window.showDirectoryPicker({ - mode: 'read', - startIn: 'desktop', - ...options, - }); - return dirHandle; - } else { - // Fallback using browser-fs-access - const dirHandle = await directoryOpen({ - mode: 'read', - ...options, - }); - return dirHandle; - } + return await window.showDirectoryPicker({ + mode: 'read', + startIn: 'desktop', + ...options, + }); } catch (error) { if (error.name === 'AbortError') { return null; // User cancelled @@ -71,66 +65,6 @@ export async function readFile(fileHandle) { } } -/** - * Read a file as text - * @param {FileSystemFileHandle} fileHandle - File handle to read - * @returns {Promise} File contents as text - */ -export async function readFileAsText(fileHandle) { - try { - const file = await fileHandle.getFile(); - const text = await file.text(); - return text; - } catch (error) { - throw new FileSystemError( - 'Failed to read file as text', - ERROR_CODES.FS_READ_ERROR, - { filename: fileHandle.name, originalError: error } - ); - } -} - -/** - * Read a file as Blob - * @param {FileSystemFileHandle} fileHandle - File handle to read - * @returns {Promise} File contents as Blob - */ -export async function readFileAsBlob(fileHandle) { - try { - const file = await fileHandle.getFile(); - return file; - } catch (error) { - throw new FileSystemError( - 'Failed to read file as blob', - ERROR_CODES.FS_READ_ERROR, - { filename: fileHandle.name, originalError: error } - ); - } -} - -/** - * Get file metadata (size, modified date, etc.) - * @param {FileSystemFileHandle} fileHandle - File handle - * @returns {Promise<{name: string, size: number, lastModified: Date}>} File metadata - */ -export async function getFileMetadata(fileHandle) { - try { - const file = await fileHandle.getFile(); - return { - name: file.name, - size: file.size, - lastModified: new Date(file.lastModified), - type: file.type, - }; - } catch (error) { - throw new FileSystemError( - 'Failed to get file metadata', - ERROR_CODES.FS_READ_ERROR, - { filename: fileHandle.name, originalError: error } - ); - } -} - /** * Save a file to the user's system * @param {Blob} blob - Blob to save @@ -340,11 +274,3 @@ export async function writeFileToPath(rootHandle, filePath, data) { ); } } - -/** - * Check if File System Access API is supported - * @returns {boolean} True if supported - */ -export function isFileSystemAccessSupported() { - return 'showDirectoryPicker' in window || typeof directoryOpen === 'function'; -} diff --git a/src/utils/koboDatabase.js b/src/utils/koboDatabase.js index 2170748..43a67cb 100644 --- a/src/utils/koboDatabase.js +++ b/src/utils/koboDatabase.js @@ -342,37 +342,6 @@ export class KoboDatabase { } } - /** - * Get books in a specific collection - * @param {string} collectionId - Collection ID - * @returns {Promise>} Array of ContentIDs - */ - async getBooksInCollection(collectionId) { - await this.ready; - - try { - const query = ` - SELECT ContentId - FROM ShelfContent - WHERE ShelfName = ? - `; - - const stmt = this.db.prepare(query); - stmt.bind([collectionId]); - - const contentIds = []; - while (stmt.step()) { - const row = stmt.getAsObject(); - contentIds.push(row.ContentId); - } - stmt.free(); - - return contentIds; - } catch (error) { - return []; - } - } - /** * Close the database connection */ diff --git a/src/utils/realBackup.test.js b/src/utils/realBackup.test.js index 72ce9b7..0486ad8 100644 --- a/src/utils/realBackup.test.js +++ b/src/utils/realBackup.test.js @@ -2,7 +2,7 @@ * Integration tests against the user's two REAL Kobo backups + an in-memory * File System Access mock so restore.js / fileSystem.js run end-to-end in node. * - * Real fixtures (not committed, ~650 MB each): + * Real fixtures (not committed, ~650 MB each) live in ./fixtures or $KOBUP_FIXTURES_DIR: * - kobo_backup_2026-01-11.zip → OLD flat format (books/title.kepub.epub) * - kobo_backup_2026-02-23.zip → NEW nested format (books/Author/title.kepub.epub) * If a fixture is missing the corresponding tests skip (CI has no fixtures), @@ -15,6 +15,7 @@ import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { openAsBlob, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; import { ZipWriter, BlobWriter, TextReader, Uint8ArrayReader, ZipReader, BlobReader, TextWriter, @@ -33,8 +34,9 @@ const { openDatabase } = await import('./koboDatabase.js'); const { exportToAnkiCsv, exportToObsidianZip, generateObsidianMarkdown } = await import('./export.js'); const initSqlJs = (await import('sql.js')).default; -const ZIP_OLD = fileURLToPath(new URL('../../kobo_backup_2026-01-11.zip', import.meta.url)); -const ZIP_NEW = fileURLToPath(new URL('../../kobo_backup_2026-02-23.zip', import.meta.url)); +const FIXTURES = process.env.KOBUP_FIXTURES_DIR || fileURLToPath(new URL('../../fixtures/', import.meta.url)); +const ZIP_OLD = join(FIXTURES, 'kobo_backup_2026-01-11.zip'); +const ZIP_NEW = join(FIXTURES, 'kobo_backup_2026-02-23.zip'); const haveOld = existsSync(ZIP_OLD); const haveNew = existsSync(ZIP_NEW); diff --git a/src/utils/restore.js b/src/utils/restore.js index 1cc42ab..c1142f3 100644 --- a/src/utils/restore.js +++ b/src/utils/restore.js @@ -422,41 +422,6 @@ export function previewBackup(backupData) { }; } -export async function extractAnnotations(backupData) { - let zipReader; - try { - // Check if annotations folder exists - zipReader = new ZipReader(new BlobReader(backupData.file)); - const entries = await zipReader.getEntries(); - const annotationsEntry = entries.find(e => e.filename === 'annotations/all-annotations.md'); - - if (annotationsEntry) { - const markdown = await annotationsEntry.getData(new TextWriter()); - return { - format: 'markdown', - content: markdown, - }; - } - - // If no pre-exported annotations, would need to parse database - // For now, return empty - return { - format: 'none', - content: '', - }; - } catch (error) { - return { - format: 'none', - content: '', - error: error.message, - }; - } finally { - if (zipReader) { - await zipReader.close(); - } - } -} - /** * Validate restore target device * @param {FileSystemDirectoryHandle} deviceHandle - Device directory handle diff --git a/src/utils/validation.js b/src/utils/validation.js index fafe350..dddfd6f 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -106,85 +106,6 @@ export function validateBackupMetadata(metadata) { return { valid: true }; } -/** - * Sanitize filename for safe download - * @param {string} filename - Original filename - * @returns {string} Sanitized filename - */ -export function sanitizeFilename(filename) { - if (!filename) return 'backup.zip'; - - return filename - .replace(/[^a-z0-9_\-\.]/gi, '_') // Replace invalid chars with underscore - .replace(/_{2,}/g, '_') // Replace multiple underscores with single - .replace(/^_|_$/g, '') // Remove leading/trailing underscores - .toLowerCase(); -} - -/** - * Validate book data structure - * @param {object} book - Book object to validate - * @returns {boolean} True if valid book structure - */ -export function isValidBook(book) { - if (!book || typeof book !== 'object') return false; - - // Check required fields - const requiredFields = ['ContentID', 'Title']; - for (const field of requiredFields) { - if (!(field in book)) return false; - } - - return true; -} - -/** - * Validate annotation data structure - * @param {object} annotation - Annotation object to validate - * @returns {boolean} True if valid annotation structure - */ -export function isValidAnnotation(annotation) { - if (!annotation || typeof annotation !== 'object') return false; - - // Must have either highlighted text or note - if (!annotation.Text && !annotation.Annotation) return false; - - return true; -} - -/** - * Check if browser supports required features - * @returns {{supported: boolean, missing: string[]}} Support check result - */ -export function checkBrowserSupport() { - const missing = []; - - // Check File System Access API - if (!('showDirectoryPicker' in window)) { - missing.push('File System Access API'); - } - - // Check FileReader API - if (!('FileReader' in window)) { - missing.push('FileReader API'); - } - - // Check WebAssembly - if (typeof WebAssembly === 'undefined') { - missing.push('WebAssembly'); - } - - // Check Blob - if (typeof Blob === 'undefined') { - missing.push('Blob API'); - } - - return { - supported: missing.length === 0, - missing, - }; -} - /** * Validate file extension * @param {string} filename - Filename to check From 138dcab1043058da7c62b7d3caa280d503c7c084 Mon Sep 17 00:00:00 2001 From: Fanfulla <224395883+Fanfulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:17:44 +0200 Subject: [PATCH 2/8] build: upgrade toolchain and migrate core to TypeScript - React 19, Vite 8 (Rolldown/Oxc), Vitest 5, Tailwind 4 (CSS-first config), Zustand 5, ESLint 10 flat config + typescript-eslint + react-hooks 7 + jsx-a11y, Prettier config, GitHub Actions CI (typecheck/lint/format/test/build) - utils, hooks, store and tests rewritten in strict TypeScript with shared domain types; common UI components (Button, Modal on native , Icon, Checkbox, ...) converted to accessible TSX - sql.js WASM is now imported via ?url so it is always in sync with the JS glue (public/sql-wasm.wasm was 1.13 while the package is 1.14) - app version injected from package.json (__APP_VERSION__) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 25 + .prettierignore | 7 + .prettierrc.json | 7 + README.md | 55 +- eslint.config.js | 34 + index.html | 103 +- package-lock.json | 7714 ++++++----------- package.json | 68 +- postcss.config.js | 6 - public/sql-wasm.wasm | Bin 659806 -> 0 bytes src/App.jsx | 93 +- src/components/AnalyticsBanner.jsx | 28 +- src/components/BrowserWarning.jsx | 59 +- src/components/ErrorBoundary.jsx | 22 +- src/components/UnsupportedBrowser.jsx | 31 +- src/components/backup/BackupConfiguration.jsx | 23 +- src/components/backup/BackupProgress.jsx | 55 +- src/components/backup/BackupSuccess.jsx | 57 +- src/components/backup/BackupWizard.jsx | 32 +- src/components/backup/CoverPreview.jsx | 43 +- src/components/backup/DeviceSelector.jsx | 40 +- src/components/backup/LibraryOverview.jsx | 77 +- src/components/backup/ScanningScreen.jsx | 38 +- src/components/common/Button.jsx | 61 - src/components/common/Button.tsx | 52 + src/components/common/Card.jsx | 41 - src/components/common/Card.tsx | 30 + src/components/common/Checkbox.jsx | 77 - src/components/common/Checkbox.tsx | 67 + src/components/common/CircularProgress.jsx | 65 - src/components/common/CircularProgress.tsx | 63 + src/components/common/Icon.jsx | 271 - src/components/common/Icon.tsx | 78 + src/components/common/Modal.jsx | 102 - src/components/common/Modal.tsx | 98 + src/components/common/ProgressBar.jsx | 44 - src/components/common/ProgressBar.tsx | 36 + src/components/common/StatusBadge.jsx | 38 - src/components/common/StatusBadge.tsx | 25 + src/components/layout/Container.jsx | 28 - src/components/layout/Container.tsx | 18 + src/components/layout/Footer.jsx | 41 +- src/components/layout/Header.jsx | 36 +- src/components/restore/BackupPreview.jsx | 68 +- src/components/restore/FileUploader.jsx | 99 +- .../restore/RestoreDeviceSelector.jsx | 57 +- src/components/restore/RestoreOptions.jsx | 36 +- src/components/restore/RestoreProgress.jsx | 30 +- src/components/restore/RestoreSuccess.jsx | 51 +- src/components/restore/RestoreWizard.jsx | 22 +- src/hooks/useBackup.js | 107 - src/hooks/useBackup.ts | 101 + src/hooks/useFeatureDetection.js | 52 - src/hooks/useFeatureDetection.ts | 40 + src/hooks/useFileSystem.js | 99 - src/hooks/useFileSystem.ts | 50 + src/hooks/useKoboDevice.js | 165 - src/hooks/useKoboDevice.ts | 74 + src/hooks/useRestore.js | 187 - src/hooks/useRestore.ts | 135 + src/main.jsx | 13 +- src/pages/FAQ.jsx | 156 +- src/pages/History.jsx | 87 +- src/pages/Home.jsx | 100 +- src/pages/LibraryDashboard.jsx | 134 +- src/pages/PrivacyPolicy.jsx | 219 +- src/pages/UserGuide.jsx | 85 +- src/stores/koboStore.js | 80 - src/stores/koboStore.ts | 72 + src/styles/animations.css | 191 - src/styles/globals.css | 215 +- src/test/builders.ts | 147 + src/test/memoryFs.ts | 131 + src/test/sqlJsNode.ts | 18 + src/types/kobo.ts | 151 + src/utils/backup.js | 261 - src/utils/backup.ts | 291 + src/utils/errors.js | 195 - src/utils/errors.ts | 63 + src/utils/export.js | 133 - src/utils/{export.test.js => export.test.ts} | 80 +- src/utils/export.ts | 109 + src/utils/fileSystem.js | 276 - src/utils/fileSystem.test.js | 23 - src/utils/fileSystem.test.ts | 27 + src/utils/fileSystem.ts | 188 + src/utils/formatters.js | 132 - src/utils/formatters.ts | 55 + src/utils/koboCovers.js | 65 - src/utils/koboCovers.test.js | 58 - src/utils/koboCovers.test.ts | 41 + src/utils/koboCovers.ts | 44 + src/utils/koboDatabase.js | 436 - src/utils/koboDatabase.ts | 287 + src/utils/realBackup.test.js | 391 - src/utils/realBackup.test.ts | 244 + src/utils/restore.js | 612 -- src/utils/restore.ts | 465 + src/utils/validation.js | 130 - src/utils/validation.ts | 56 + src/vite-env.d.ts | 4 + tailwind.config.js | 87 - tsconfig.app.json | 27 + tsconfig.json | 8 + tsconfig.node.json | 22 + tsconfig.test.json | 8 + vite.config.js | 31 - vite.config.ts | 35 + 108 files changed, 7068 insertions(+), 10876 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 eslint.config.js delete mode 100644 postcss.config.js delete mode 100644 public/sql-wasm.wasm delete mode 100644 src/components/common/Button.jsx create mode 100644 src/components/common/Button.tsx delete mode 100644 src/components/common/Card.jsx create mode 100644 src/components/common/Card.tsx delete mode 100644 src/components/common/Checkbox.jsx create mode 100644 src/components/common/Checkbox.tsx delete mode 100644 src/components/common/CircularProgress.jsx create mode 100644 src/components/common/CircularProgress.tsx delete mode 100644 src/components/common/Icon.jsx create mode 100644 src/components/common/Icon.tsx delete mode 100644 src/components/common/Modal.jsx create mode 100644 src/components/common/Modal.tsx delete mode 100644 src/components/common/ProgressBar.jsx create mode 100644 src/components/common/ProgressBar.tsx delete mode 100644 src/components/common/StatusBadge.jsx create mode 100644 src/components/common/StatusBadge.tsx delete mode 100644 src/components/layout/Container.jsx create mode 100644 src/components/layout/Container.tsx delete mode 100644 src/hooks/useBackup.js create mode 100644 src/hooks/useBackup.ts delete mode 100644 src/hooks/useFeatureDetection.js create mode 100644 src/hooks/useFeatureDetection.ts delete mode 100644 src/hooks/useFileSystem.js create mode 100644 src/hooks/useFileSystem.ts delete mode 100644 src/hooks/useKoboDevice.js create mode 100644 src/hooks/useKoboDevice.ts delete mode 100644 src/hooks/useRestore.js create mode 100644 src/hooks/useRestore.ts delete mode 100644 src/stores/koboStore.js create mode 100644 src/stores/koboStore.ts delete mode 100644 src/styles/animations.css create mode 100644 src/test/builders.ts create mode 100644 src/test/memoryFs.ts create mode 100644 src/test/sqlJsNode.ts create mode 100644 src/types/kobo.ts delete mode 100644 src/utils/backup.js create mode 100644 src/utils/backup.ts delete mode 100644 src/utils/errors.js create mode 100644 src/utils/errors.ts delete mode 100644 src/utils/export.js rename src/utils/{export.test.js => export.test.ts} (58%) create mode 100644 src/utils/export.ts delete mode 100644 src/utils/fileSystem.js delete mode 100644 src/utils/fileSystem.test.js create mode 100644 src/utils/fileSystem.test.ts create mode 100644 src/utils/fileSystem.ts delete mode 100644 src/utils/formatters.js create mode 100644 src/utils/formatters.ts delete mode 100644 src/utils/koboCovers.js delete mode 100644 src/utils/koboCovers.test.js create mode 100644 src/utils/koboCovers.test.ts create mode 100644 src/utils/koboCovers.ts delete mode 100644 src/utils/koboDatabase.js create mode 100644 src/utils/koboDatabase.ts delete mode 100644 src/utils/realBackup.test.js create mode 100644 src/utils/realBackup.test.ts delete mode 100644 src/utils/restore.js create mode 100644 src/utils/restore.ts delete mode 100644 src/utils/validation.js create mode 100644 src/utils/validation.ts create mode 100644 src/vite-env.d.ts delete mode 100644 tailwind.config.js create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 tsconfig.test.json delete mode 100644 vite.config.js create mode 100644 vite.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67c0275 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm run format:check + - run: npm test + - run: npm run build diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..39feb03 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +dist +dist-ssr +coverage +fixtures +node_modules +package-lock.json +public diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b3ef7f6 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "semi": true, + "trailingComma": "all", + "printWidth": 110, + "endOfLine": "auto" +} diff --git a/README.md b/README.md index 516117a..403e5b3 100644 --- a/README.md +++ b/README.md @@ -67,14 +67,14 @@ Whether you want to protect against a factory reset, switch to a new Kobo device ## 🌐 Browser Compatibility -| Browser | Backup | Restore | Notes | -|---|---|---|---| -| **Chrome 86+** | ✅ | ✅ | Recommended | -| **Edge 86+** | ✅ | ✅ | Fully supported | -| **Opera 72+** | ✅ | ✅ | Fully supported | -| **Brave** | ⚠️ | ⚠️ | Enable File System API in settings | -| **Firefox** | ❌ | ❌ | File System Access API not supported | -| **Safari** | ❌ | ❌ | File System Access API not available | +| Browser | Backup | Restore | Notes | +| -------------- | ------ | ------- | ------------------------------------ | +| **Chrome 86+** | ✅ | ✅ | Recommended | +| **Edge 86+** | ✅ | ✅ | Fully supported | +| **Opera 72+** | ✅ | ✅ | Fully supported | +| **Brave** | ⚠️ | ⚠️ | Enable File System API in settings | +| **Firefox** | ❌ | ❌ | File System Access API not supported | +| **Safari** | ❌ | ❌ | File System Access API not available | > **Why Chrome/Edge only?** KoBup uses the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API) to read directly from your Kobo device and write the backup directly to disk without loading everything into memory. This API is only available in Chromium-based browsers. @@ -83,15 +83,18 @@ Whether you want to protect against a factory reset, switch to a new Kobo device ## 📋 What Gets Backed Up? ### Always Included + - ✅ **Kobo Database** (`KoboReader.sqlite`) — reading progress, bookmarks, highlights, collections -- ✅ **Book Files** — all sideloaded EPUB, PDF, and other ebook files *(optional: can be skipped for a database-only backup)* +- ✅ **Book Files** — all sideloaded EPUB, PDF, and other ebook files _(optional: can be skipped for a database-only backup)_ ### Optional (User Choice) + - 📝 **Annotations & Highlights** — all your notes and highlighted passages, also exported as Markdown - 📖 **Reading Progress** — current page positions and reading statistics - ⚙️ **Device Settings** — font and reading preferences ### Not Included + - ❌ **Kobo Store Purchases** — re-downloadable from your Kobo account - ❌ **System / Firmware Files** — not needed for library backup @@ -113,19 +116,19 @@ The complete source code is public on GitHub. You can audit every line, or self- ## 🛠️ Technology Stack -| Layer | Technology | -|---|---| -| UI Framework | React 18.3+ | -| Build Tool | Vite 5+ | -| Styling | TailwindCSS 3.4+ | -| State | Zustand | -| SQLite in browser | sql.js (WebAssembly) | -| ZIP creation | **client-zip** (streaming, replaces JSZip) | -| ZIP extraction | **@zip.js/zip.js** (streaming, replaces JSZip) | -| File System | File System Access API + browser-fs-access | -| Testing | Vitest | -| Analytics | Vercel Analytics (cookieless) | -| Hosting | Vercel | +| Layer | Technology | +| ----------------- | ---------------------------------------------- | +| UI Framework | React 18.3+ | +| Build Tool | Vite 5+ | +| Styling | TailwindCSS 3.4+ | +| State | Zustand | +| SQLite in browser | sql.js (WebAssembly) | +| ZIP creation | **client-zip** (streaming, replaces JSZip) | +| ZIP extraction | **@zip.js/zip.js** (streaming, replaces JSZip) | +| File System | File System Access API + browser-fs-access | +| Testing | Vitest | +| Analytics | Vercel Analytics (cookieless) | +| Hosting | Vercel | --- @@ -208,7 +211,8 @@ MIT License — see [LICENSE](LICENSE) for details. ## 🗺️ Changelog ### v1.2.1 (May 2026) — hardening -- **Safer restore (behavior change)** — "Remove existing book folders" is now **opt-in**. By default restore overwrites backed-up files without recursively deleting device folders, so books added *after* a backup are no longer wiped when restoring an older one. + +- **Safer restore (behavior change)** — "Remove existing book folders" is now **opt-in**. By default restore overwrites backed-up files without recursively deleting device folders, so books added _after_ a backup are no longer wiped when restoring an older one. - **Path-traversal guard** — restore refuses any book path containing `..` (defense-in-depth against a crafted/corrupt backup ZIP), and the cleanup step never touches protected folders (`.kobo`, etc.). - **CSV injection guard** — Anki export prefixes fields starting with `= + - @` so they can't run as formulas if opened in a spreadsheet. - **Obsidian export** — de-duplicates filenames so books with identical titles no longer overwrite each other. @@ -216,6 +220,7 @@ MIT License — see [LICENSE](LICENSE) for details. - **Tests** — added an integration suite that runs the restore engine end-to-end against an in-memory device and real Kobo backups (both flat and nested formats). ### v1.2 (May 2026) + - **Library Dashboard** — responsive UI grid to view connected books, filter by status, read metadata, and check reading time/metrics. - **Kobo Covers Extractor** — parse real book covers from `.kobo/images` using suffix match resolution logic. - **Advanced Notes Exporter** — export all highlights to Obsidian Markdown ZIP (with YAML frontmatter metadata) or Anki CSV (escaped flashcards). @@ -223,6 +228,7 @@ MIT License — see [LICENSE](LICENSE) for details. - **Vitest Test Suite** — added unit tests for cover parsing and Obsidian/Anki exporters. ### v1.1 (March 2026) + - **Streaming backup engine** — replaced JSZip with client-zip; files are written to disk one at a time, fixing OOM crashes on large libraries (4 GB+, 500+ books) - **Database-only backup** — option to skip book files and back up only reading progress, annotations, and highlights - **Accurate size estimate** — estimated backup size now reflects actual file sizes @@ -230,6 +236,7 @@ MIT License — see [LICENSE](LICENSE) for details. - **Vercel Analytics** — cookieless, privacy-preserving page-view tracking ### v1.0 (January 2026) + - Initial release - Full backup and restore functionality - Browser compatibility detection @@ -237,4 +244,4 @@ MIT License — see [LICENSE](LICENSE) for details. --- -*Made with ❤️ for the Kobo community — [kobup.org](https://www.kobup.org/)* +_Made with ❤️ for the Kobo community — [kobup.org](https://www.kobup.org/)_ diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..009d0e6 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,34 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import jsxA11y from 'eslint-plugin-jsx-a11y-x'; +import tseslint from 'typescript-eslint'; +import { defineConfig, globalIgnores } from 'eslint/config'; + +export default defineConfig([ + globalIgnores(['dist', 'dist-ssr', 'coverage', 'fixtures', 'node_modules']), + { + files: ['**/*.{js,jsx,ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + jsxA11y.configs.recommended, + ], + languageOptions: { + ecmaVersion: 2023, + globals: globals.browser, + parserOptions: { ecmaFeatures: { jsx: true } }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrors: 'none' }], + }, + }, + { + files: ['**/*.test.{js,jsx,ts,tsx}', 'scripts/**', '*.config.{js,ts}'], + languageOptions: { globals: { ...globals.node, ...globals.browser } }, + rules: { 'react-refresh/only-export-components': 'off' }, + }, +]); diff --git a/index.html b/index.html index d758b8b..2223ce1 100644 --- a/index.html +++ b/index.html @@ -8,8 +8,14 @@ PRIMARY SEO ====================================================================== --> KoBup — Free Kobo Backup Tool | Backup Your Kobo Library Online - - + + @@ -20,7 +26,10 @@ - + @@ -34,51 +43,52 @@ - + - - - + + +
diff --git a/package-lock.json b/package-lock.json index d7fceff..d8a43df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,59 +9,54 @@ "version": "1.0.0", "dependencies": { "@vercel/analytics": "^2.0.1", - "@zip.js/zip.js": "^2.8.26", - "client-zip": "^2.5.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "sql.js": "^1.10.3", - "zustand": "^4.5.0" + "@zip.js/zip.js": "^2.16.0", + "client-zip": "^2.5.1", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "sql.js": "^1.14.2", + "zustand": "^5.0.15" }, "devDependencies": { - "@testing-library/jest-dom": "^6.4.6", - "@testing-library/react": "^16.0.0", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "autoprefixer": "^10.4.19", - "eslint": "^8.57.0", - "eslint-plugin-react": "^7.34.2", - "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.7", - "postcss": "^8.4.38", - "prettier": "^3.3.2", - "tailwindcss": "^3.4.4", - "vite": "^5.3.1", - "vitest": "^1.6.0" + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^22.20.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@types/sql.js": "^1.4.11", + "@types/wicg-file-system-access": "^2023.10.7", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.11.0", + "eslint-plugin-jsx-a11y-x": "^0.2.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.12.0", + "happy-dom": "^20.14.5", + "prettier": "^3.9.8", + "tailwindcss": "^4.3.3", + "typescript": "^6.0.3", + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0", + "vitest": "^5.0.1" } }, "node_modules/@adobe/css-tools": { - "version": "4.4.4", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", - "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", "dev": true, "license": "MIT" }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -70,9 +65,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -80,21 +75,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", - "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -111,14 +106,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -128,14 +123,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -145,9 +140,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -155,29 +150,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -186,20 +181,10 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -207,9 +192,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -217,9 +202,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -227,27 +212,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -256,136 +241,372 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "eslint-visitor-keys": "^3.4.3" }, "engines": { - "node": ">=6.9.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", - "debug": "^4.3.1" + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" }, "engines": { - "node": ">=6.9.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "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" - ], + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], "engines": { - "node": ">=12" + "node": ">=6.0.0" } }, - "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" - ], + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, "engines": { - "node": ">=12" + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.151.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.151.0.tgz", + "integrity": "sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" } }, - "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==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", + "integrity": "sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==", "cpu": [ - "arm64" + "arm" ], "dev": true, "license": "MIT", @@ -394,15 +615,15 @@ "android" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.11.tgz", + "integrity": "sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -411,13 +632,13 @@ "android" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.11.tgz", + "integrity": "sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==", "cpu": [ "arm64" ], @@ -428,13 +649,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.11.tgz", + "integrity": "sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==", "cpu": [ "x64" ], @@ -445,15 +666,15 @@ "darwin" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.11.tgz", + "integrity": "sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", @@ -462,32 +683,32 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.11.tgz", + "integrity": "sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==", "cpu": [ - "x64" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "freebsd" + "linux" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.11.tgz", + "integrity": "sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==", "cpu": [ - "arm" + "arm64" ], "dev": true, "license": "MIT", @@ -496,13 +717,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.11.tgz", + "integrity": "sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==", "cpu": [ "arm64" ], @@ -513,15 +734,15 @@ "linux" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.11.tgz", + "integrity": "sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==", "cpu": [ - "ia32" + "ppc64" ], "dev": true, "license": "MIT", @@ -530,15 +751,15 @@ "linux" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.11.tgz", + "integrity": "sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==", "cpu": [ - "loong64" + "s390x" ], "dev": true, "license": "MIT", @@ -547,15 +768,15 @@ "linux" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.11.tgz", + "integrity": "sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==", "cpu": [ - "mips64el" + "x64" ], "dev": true, "license": "MIT", @@ -564,15 +785,15 @@ "linux" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.11.tgz", + "integrity": "sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==", "cpu": [ - "ppc64" + "x64" ], "dev": true, "license": "MIT", @@ -581,47 +802,47 @@ "linux" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.11.tgz", + "integrity": "sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==", "cpu": [ - "riscv64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openharmony" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.11.tgz", + "integrity": "sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==", "cpu": [ - "s390x" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" } }, - "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==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.11.tgz", + "integrity": "sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==", "cpu": [ "x64" ], @@ -629,50 +850,97 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { - "node": ">=12" + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" } }, - "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==", + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "netbsd" + "android" ], "engines": { - "node": ">=12" + "node": ">= 20" } }, - "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==", + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "openbsd" + "darwin" ], "engines": { - "node": ">=12" + "node": ">= 20" } }, - "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==", + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -680,3619 +948,1447 @@ "license": "MIT", "optional": true, "os": [ - "sunos" + "darwin" ], "engines": { - "node": ">=12" + "node": ">= 20" } }, - "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==", + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "freebsd" ], "engines": { - "node": ">=12" + "node": ">= 20" } }, - "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==", + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ - "ia32" + "arm" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=12" + "node": ">= 20" } }, - "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==", + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { - "node": ">=12" + "node": ">= 20" } }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": ">= 20" } }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + "node": ">= 20" } }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "node": ">= 20" } }, - "node_modules/@eslint/js": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", - "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", - "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", - "deprecated": "Use @eslint/config-array instead", - "dev": true, - "license": "Apache-2.0", + "optional": true, "dependencies": { - "@humanwhocodes/object-schema": "^2.0.3", - "debug": "^4.3.1", - "minimatch": "^3.0.5" + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" }, "engines": { - "node": ">=10.10.0" + "node": ">=14.0.0" } }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" + "node": ">= 20" } }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", - "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", - "deprecated": "Use @eslint/object-schema instead", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 20" } }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" } }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, "engines": { - "node": ">=6.0.0" + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, - "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==", + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, "license": "MIT" }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">= 8" + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } + "license": "MIT" }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", - "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", - "cpu": [ - "arm" - ], + "node_modules/@types/emscripten": { + "version": "1.41.6", + "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.6.tgz", + "integrity": "sha512-uN+9i8bFT5CUcZfyIEYDrSueACEyKGbUs5kC/72DGlZZoinh84sJfVV0i8UOJD1asdzkvLPBRrKs41kZ8MdEXg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", - "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", - "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", - "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", - "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", - "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", - "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", - "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", - "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", - "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", - "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", - "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", - "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", - "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", - "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", - "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", - "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", - "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", - "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", - "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", - "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", - "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, "license": "MIT" }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "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/@testing-library/react": { - "version": "16.3.1", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.1.tgz", - "integrity": "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@testing-library/dom": "^10.0.0", - "@types/react": "^18.0.0 || ^19.0.0", - "@types/react-dom": "^18.0.0 || ^19.0.0", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.27", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", - "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vercel/analytics": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", - "integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==", - "license": "MIT", - "peerDependencies": { - "@remix-run/react": "^2", - "@sveltejs/kit": "^1 || ^2", - "next": ">= 13", - "nuxt": ">= 3", - "react": "^18 || ^19 || ^19.0.0-rc", - "svelte": ">= 4", - "vue": "^3", - "vue-router": "^4" - }, - "peerDependenciesMeta": { - "@remix-run/react": { - "optional": true - }, - "@sveltejs/kit": { - "optional": true - }, - "next": { - "optional": true - }, - "nuxt": { - "optional": true - }, - "react": { - "optional": true - }, - "svelte": { - "optional": true - }, - "vue": { - "optional": true - }, - "vue-router": { - "optional": true - } - } - }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", - "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "chai": "^4.3.10" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", - "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "1.6.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vitest/runner/node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@vitest/snapshot": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", - "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@vitest/snapshot/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@vitest/snapshot/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/spy": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", - "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^2.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", - "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@vitest/utils/node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@vitest/utils/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@zip.js/zip.js": { - "version": "2.8.26", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz", - "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==", - "license": "BSD-3-Clause", - "engines": { - "bun": ">=0.7.0", - "deno": ">=1.0.0", - "node": ">=18.0.0" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/autoprefixer": { - "version": "10.4.23", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", - "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001760", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.11", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", - "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001761", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", - "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/client-zip": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/client-zip/-/client-zip-2.5.0.tgz", - "integrity": "sha512-ydG4nDZesbFurnNq0VVCp/yyomIBh+X/1fZPI/P24zbnG4dtC4tQAfI5uQsomigsUMeiRO2wiTPizLWQh+IAyQ==", - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true, - "license": "ISC" - }, - "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz", - "integrity": "sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.5", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint": { - "version": "8.57.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz", - "integrity": "sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.26", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", - "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "eslint": ">=8.40" - } - }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } + "license": "MIT" }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", "dev": true, "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "undici-types": "~6.21.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "dev": true, "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" + "peerDependencies": { + "@types/react": "^19.3.0" } }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/@types/sql.js": { + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz", + "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" + "@types/emscripten": "*", + "@types/node": "*" } }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", "dev": true, "license": "MIT" }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "node_modules/@types/wicg-file-system-access": { + "version": "2023.10.7", + "resolved": "https://registry.npmjs.org/@types/wicg-file-system-access/-/wicg-file-system-access-2023.10.7.tgz", + "integrity": "sha512-g49ijasEJvCd7ifmAY2D0wdEtt1xRjBbA33PJTiv8mKBr7DoMsPeISoJ8oQOTopSRi+FBWPpPW5ouDj2QPKtGA==", "dev": true, "license": "MIT" }, - "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "reusify": "^1.0.4" + "@types/node": "*" } }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", + "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/type-utils": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.70.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", "dev": true, "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">= 4" } }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/@typescript-eslint/parser": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", + "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3" }, "engines": { - "node": ">=10" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", - "dev": true, - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", + "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", "dev": true, "license": "MIT", "dependencies": { - "is-callable": "^1.2.7" + "@typescript-eslint/tsconfig-utils": "^8.70.0", + "@typescript-eslint/types": "^8.70.0", + "debug": "^4.4.3" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/fraction.js": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "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/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", + "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", + "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", "dev": true, "license": "MIT", "engines": { - "node": "*" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", + "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", + "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" + "@typescript-eslint/project-service": "8.70.0", + "@typescript-eslint/tsconfig-utils": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/visitor-keys": "8.70.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=10.13.0" + "node": ">=10" } }, - "node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/@typescript-eslint/utils": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", + "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.20.2" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.70.0", + "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0" }, "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", + "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", "dev": true, "license": "MIT", "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" + "@typescript-eslint/types": "8.70.0", + "eslint-visitor-keys": "^5.0.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, + "node_modules/@vercel/analytics": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz", + "integrity": "sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "@remix-run/react": "^2", + "@sveltejs/kit": "^1 || ^2", + "next": ">= 13", + "nuxt": ">= 3", + "react": "^18 || ^19 || ^19.0.0-rc", + "svelte": ">= 4", + "vue": "^3", + "vue-router": "^4" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependenciesMeta": { + "@remix-run/react": { + "optional": true + }, + "@sveltejs/kit": { + "optional": true + }, + "next": { + "optional": true + }, + "nuxt": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + }, + "vue-router": { + "optional": true + } } }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=8" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" - }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/vitest" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, + "node_modules/@zip.js/zip.js": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.16.0.tgz", + "integrity": "sha512-D2DZ0Gdn4TP0HlIERuLLRyzKd65C7YUqxF2A6tpthM9TAcWBE+oWGvN52tJ12smenv6vo9J2tXVX4Obzh6skuA==", + "license": "BSD-3-Clause", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bun": ">=0.7.0", + "deno": ">=1.0.0", + "node": ">=18.0.0" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" + "node": ">=0.4.0" } }, - "node_modules/ignore": { + "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 4" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "license": "ISC", + "license": "Apache-2.0", "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "dequal": "^2.0.3" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, "engines": { - "node": ">= 0.4" + "node": ">=12" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/axe-core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, + "license": "MPL-2.0", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4" } }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, + "license": "Apache-2.0", "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "18 || 20 || >=22" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.26.tgz", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, "engines": { - "node": ">=8" + "node": ">=6.0.0" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" + "balanced-match": "^4.0.2" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "20 || >=22" } }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "node_modules/browserslist": { + "version": "4.29.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.1.tgz", + "integrity": "sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "hasown": "^2.0.2" + "baseline-browser-mapping": "^2.11.25", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.438", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, - "engines": { - "node": ">= 0.4" + "bin": { + "browserslist": "cli.js" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" + "@types/node": "*" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/caniuse-lite": { + "version": "1.0.30001812", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001812.tgz", + "integrity": "sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=18" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "node_modules/client-zip": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/client-zip/-/client-zip-2.5.1.tgz", + "integrity": "sha512-yOvNSBsQX1B7wtdvIVXJEIIShrlPiX+adhli7sWvQhsx3nX1DjvpSt+AwOxaX+3DCsT/Yx0nRy5hIjRLIYfebw==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 8" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "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": { - "is-extglob": "^2.1.1" + "ms": "^2.1.3" }, "engines": { - "node": ">=0.10.0" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, - "node_modules/is-negative-zero": { + "node_modules/dequal": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=0.12.0" + "node": ">=8" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.439", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.439.tgz", + "integrity": "sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10.13.0" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">=8" + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { - "call-bound": "^1.0.3" + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } } }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "node_modules/eslint-plugin-jsx-a11y-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y-x/-/eslint-plugin-jsx-a11y-x-0.2.0.tgz", + "integrity": "sha512-O/VpKt2G38VCMlBgGCH8eJGWXM05z892zGKeuPfPATMPLDZOWeVtKS8SBt98ElaeFRKcJEv2bELub+Llo3m9zw==", "dev": true, "license": "MIT", + "dependencies": { + "aria-query": "^5.3.2", + "axe-core": "^4.10.2", + "axobject-query": "^4.1.0", + "damerau-levenshtein": "^1.0.8", + "jsx-ast-utils-x": "^0.1.0", + "language-tags": "^1.0.9", + "minimatch": "^10.2.5" + }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": "^20.9.0 || >=21.1.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/eslint-plugin-jsx-a11y-x/node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, + "license": "Apache-2.0", "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.7.tgz", + "integrity": "sha512-XhJSzLljuYD4UjNuFGJu2v7aD3sqNVK12w5/DCUfrNfHWegZo2+TsavNx8MuxMwEquuFAzNbNfXKU0WoZ+YUIg==", "dev": true, "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", "dependencies": { - "which-typed-array": "^1.1.16" + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "call-bound": "^1.0.3" + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "estraverse": "^5.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" + "node": ">=4.0" } }, - "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", - "bin": { - "jiti": "bin/jiti.js" + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "@types/estree": "^1.0.0" } }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, + "license": "BSD-2-Clause", "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, + "license": "Apache-2.0", "engines": { - "node": ">=6" + "node": ">=12.0.0" } }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } + "license": "MIT" }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } + "license": "MIT" }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } + "license": "MIT" }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "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": ">=14" + "node": ">=12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/antonk52" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/local-pkg": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", - "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", "dev": true, "license": "MIT", "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "flat-cache": "^6.1.23" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" }, "engines": { "node": ">=10" @@ -4301,1215 +2397,984 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", "dev": true, "license": "MIT", "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" } }, - "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==", + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } + "license": "ISC" }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "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": ">= 0.4" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 8" + "node": ">=6.9.0" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" + "is-glob": "^4.0.3" }, "engines": { - "node": ">=8.6" + "node": ">=10.13.0" } }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "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": "MIT", - "engines": { - "node": ">=4" - } + "license": "ISC" }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/happy-dom": { + "version": "20.14.5", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.14.5.tgz", + "integrity": "sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" }, "engines": { - "node": "*" + "node": ">=20.0.0" } }, - "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", "dev": true, "license": "MIT", "dependencies": { - "acorn": "^8.15.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" } }, - "node_modules/mlly/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/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", "dev": true, "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", "dev": true, "license": "MIT", "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "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" + "hermes-estree": "0.25.1" } }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 4" } }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.8.19" } }, - "node_modules/npm-run-path/node_modules/path-key": { + "node_modules/indent-string": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, "engines": { - "node": ">= 6" + "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "ISC" }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "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", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" + "bin": { + "jsesc": "bin/jsesc" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "node_modules/jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@keyv/serialize": "^1.1.1" } }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } + "license": "CC0-1.0" }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", "dev": true, "license": "MIT", "dependencies": { - "mimic-fn": "^4.0.0" + "language-subtag-registry": "^0.3.20" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10" } }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" + "type-check": "~0.4.0" }, "engines": { "node": ">= 0.8.0" } }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" + "type": "opencollective", + "url": "https://opencollective.com/parcel" }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8.6" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "bin": { + "lz-string": "bin/bin.js" } }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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, - "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.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", "dev": true, "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" + "node": ">=4" } }, - "node_modules/postcss-import/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "brace-expansion": "^5.0.8" }, "engines": { - "node": ">= 0.4" + "node": "18 || 20 || >=22" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "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.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "dev": true, "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" + "bin": { + "nanoid": "bin/nanoid.cjs" }, "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.57", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">=18" } }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", "dev": true, "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" ], "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" + "node": ">=12.20.0" } }, - "node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", "engines": { "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "yocto-queue": "^0.1.0" }, "engines": { - "node": ">=14" + "node": ">=10" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" + "p-limit": "^3.0.2" }, "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=8" } }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" + "engines": { + "node": ">=8" } }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "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": "MIT" + "license": "ISC" }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { - "type": "github", - "url": "https://github.com/sponsors/feross" + "type": "opencollective", + "url": "https://opencollective.com/postcss/" }, { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], - "license": "MIT" - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "license": "MIT", "dependencies": { - "picomatch": "^2.2.1" + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=8.10.0" + "node": "^10 || ^12 || >=14" } }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "node_modules/prettier": { + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", + "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" + "bin": { + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">= 0.4" + "node": ">=14" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/prettier/prettier?sponsor=1" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, - "node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "engines": { + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { - "node": ">=4" + "node": ">=6" } }, - "node_modules/reusify": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", - "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", "dev": true, "license": "MIT", - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" + "hookified": "^2.1.1" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">=20" } }, - "node_modules/rollup": { - "version": "4.54.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", - "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", "dev": true, + "license": "MIT" + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.54.0", - "@rollup/rollup-android-arm64": "4.54.0", - "@rollup/rollup-darwin-arm64": "4.54.0", - "@rollup/rollup-darwin-x64": "4.54.0", - "@rollup/rollup-freebsd-arm64": "4.54.0", - "@rollup/rollup-freebsd-x64": "4.54.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", - "@rollup/rollup-linux-arm-musleabihf": "4.54.0", - "@rollup/rollup-linux-arm64-gnu": "4.54.0", - "@rollup/rollup-linux-arm64-musl": "4.54.0", - "@rollup/rollup-linux-loong64-gnu": "4.54.0", - "@rollup/rollup-linux-ppc64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-gnu": "4.54.0", - "@rollup/rollup-linux-riscv64-musl": "4.54.0", - "@rollup/rollup-linux-s390x-gnu": "4.54.0", - "@rollup/rollup-linux-x64-gnu": "4.54.0", - "@rollup/rollup-linux-x64-musl": "4.54.0", - "@rollup/rollup-openharmony-arm64": "4.54.0", - "@rollup/rollup-win32-arm64-msvc": "4.54.0", - "@rollup/rollup-win32-ia32-msvc": "4.54.0", - "@rollup/rollup-win32-x64-gnu": "4.54.0", - "@rollup/rollup-win32-x64-msvc": "4.54.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" + "node": ">=0.10.0" } }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" + "scheduler": "^0.28.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^19.3.0" } }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, "license": "MIT" }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/safe-push-apply/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "node_modules/rolldown": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", + "integrity": "sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" + "@oxc-project/types": "=0.151.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">= 0.4" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.11", + "@rolldown/binding-android-arm64": "1.2.11", + "@rolldown/binding-darwin-arm64": "1.2.11", + "@rolldown/binding-darwin-x64": "1.2.11", + "@rolldown/binding-freebsd-x64": "1.2.11", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.11", + "@rolldown/binding-linux-arm64-gnu": "1.2.11", + "@rolldown/binding-linux-arm64-musl": "1.2.11", + "@rolldown/binding-linux-ppc64-gnu": "1.2.11", + "@rolldown/binding-linux-s390x-gnu": "1.2.11", + "@rolldown/binding-linux-x64-gnu": "1.2.11", + "@rolldown/binding-linux-x64-musl": "1.2.11", + "@rolldown/binding-openharmony-arm64": "1.2.11", + "@rolldown/binding-win32-arm64-msvc": "1.2.11", + "@rolldown/binding-win32-x64-msvc": "1.2.11" } }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" }, "node_modules/semver": { "version": "6.3.1", @@ -5521,55 +3386,6 @@ "semver": "bin/semver.js" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5593,82 +3409,6 @@ "node": ">=8" } }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -5676,19 +3416,6 @@ "dev": true, "license": "ISC" }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5700,9 +3427,9 @@ } }, "node_modules/sql.js": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.13.0.tgz", - "integrity": "sha512-RJbVP1HRDlUUXahJ7VMTcu9Rm1Nzw+EBpoPr94vnbD4LwR715F3CcxE2G2k45PewcaZ57pjetYa+LoSJLAASgA==", + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz", + "integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==", "license": "MIT" }, "node_modules/stackback": { @@ -5712,150 +3439,12 @@ "dev": true, "license": "MIT" }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, "node_modules/strip-indent": { "version": "3.0.0", @@ -5870,605 +3459,582 @@ "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", "dev": true, "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" + "engines": { + "node": ">=20.0.0" } }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=18" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "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": { - "has-flag": "^4.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": ">=8" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=18.12" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/tailwindcss": { - "version": "3.4.19", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", - "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.6.0", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.2", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.7", - "lilconfig": "^3.1.3", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.47", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", - "postcss-nested": "^6.2.0", - "postcss-selector-parser": "^6.1.2", - "resolve": "^1.22.8", - "sucrase": "^3.35.0" + "prelude-ls": "^1.2.1" }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.0.0" + "node": ">=14.17" } }, - "node_modules/tailwindcss/node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "node_modules/typescript-eslint": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", + "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", "dev": true, "license": "MIT", "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "@typescript-eslint/eslint-plugin": "8.70.0", + "@typescript-eslint/parser": "8.70.0", + "@typescript-eslint/typescript-estree": "8.70.0", + "@typescript-eslint/utils": "8.70.0" }, "engines": { - "node": ">= 0.4" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/text-table": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, "license": "MIT" }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "any-promise": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" + "punycode": "^2.1.0" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" }, "engines": { - "node": ">=12.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/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" + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" }, "peerDependencies": { - "picomatch": "^3 || ^4" + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { - "picomatch": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { "optional": true } } }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinypool": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", - "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", "dependencies": { - "is-number": "^7.0.0" + "detect-libc": "^2.0.3" }, "engines": { - "node": ">=8.0" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" + "node": ">= 12.0.0" }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=4" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "(MIT OR CC0-1.0)", + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" + "engines": { + "node": ">= 12.0.0" }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">= 12.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 - } + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/vite-node": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", - "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://opencollective.com/vitest" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/vitest": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", - "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "1.6.1", - "@vitest/runner": "1.6.1", - "@vitest/snapshot": "1.6.1", - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", - "vite": "^5.0.0", - "vite-node": "1.6.1", - "why-is-node-running": "^2.2.2" + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.6.1", - "@vitest/ui": "1.6.1", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { "optional": true }, + "@opentelemetry/api": { + "optional": true + }, "@types/node": { "optional": true }, - "@vitest/browser": { + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { "optional": true }, "@vitest/ui": { @@ -6479,119 +4045,46 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz", + "integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==", "dev": true, "license": "MIT", "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=12" } }, - "node_modules/which-builtin-type/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" + "isexe": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" + "bin": { + "node-which": "bin/node-which" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 8" } }, "node_modules/why-is-node-running": { @@ -6621,12 +4114,27 @@ "node": ">=0.10.0" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } }, "node_modules/yallist": { "version": "3.1.1", @@ -6648,21 +4156,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zustand": { - "version": "4.5.7", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", - "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", "engines": { - "node": ">=12.7.0" + "node": ">=12.20.0" }, "peerDependencies": { - "@types/react": ">=16.8", + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -6673,6 +4202,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/package.json b/package.json index d080c1f..a4142d9 100644 --- a/package.json +++ b/package.json @@ -1,40 +1,56 @@ { - "name": "kobo-backup-manager", + "name": "kobup", "private": true, - "version": "1.0.0", + "version": "1.3.0", "type": "module", - "description": "A free, web-based backup manager for Kobo e-readers that works 100% in your browser", + "description": "KoBup - free, privacy-first backup & restore for Kobo e-readers that runs 100% in your browser", + "engines": { + "node": ">=22.12.0" + }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", - "test": "vitest", - "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0" + "typecheck": "tsc -b", + "lint": "eslint . --max-warnings 0", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest", + "check": "npm run typecheck && npm run lint && npm run test && npm run build" }, "dependencies": { "@vercel/analytics": "^2.0.1", - "@zip.js/zip.js": "^2.8.26", - "client-zip": "^2.5.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "sql.js": "^1.10.3", - "zustand": "^4.5.0" + "@zip.js/zip.js": "^2.16.0", + "client-zip": "^2.5.1", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "sql.js": "^1.14.2", + "zustand": "^5.0.15" }, "devDependencies": { - "@testing-library/jest-dom": "^6.4.6", - "@testing-library/react": "^16.0.0", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@vitejs/plugin-react": "^4.3.1", - "autoprefixer": "^10.4.19", - "eslint": "^8.57.0", - "eslint-plugin-react": "^7.34.2", - "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.7", - "postcss": "^8.4.38", - "prettier": "^3.3.2", - "tailwindcss": "^3.4.4", - "vite": "^5.3.1", - "vitest": "^1.6.0" + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.3", + "@testing-library/dom": "^10.4.2", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", + "@types/node": "^22.20.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@types/sql.js": "^1.4.11", + "@types/wicg-file-system-access": "^2023.10.7", + "@vitejs/plugin-react": "^6.1.1", + "eslint": "^10.11.0", + "eslint-plugin-jsx-a11y-x": "^0.2.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.7", + "globals": "^17.12.0", + "happy-dom": "^20.14.5", + "prettier": "^3.9.8", + "tailwindcss": "^4.3.3", + "typescript": "~6.0.3", + "typescript-eslint": "^8.70.0", + "vite": "^8.3.0", + "vitest": "^5.0.1" } } diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/public/sql-wasm.wasm b/public/sql-wasm.wasm deleted file mode 100644 index f2497a7bff9462c3282784d798eb38f4405d0d6e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 659806 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

}UdeGzA0oi@K1$$QDnOSL*6*4Xz*_UjJY zNcKz4{gm2x7<@56fSlHV4YL1+CE19=vRQt5&RP|M{`!)oP#PfN@hWzFiMoFK_27=< z)~vzjgNEG6_nLy4-}|DWx!9}R?1ke19?(Pr=Elil z*W{+o(Sz_O9E}AHy8W6j_|ZegVS8b#wV(bv_0+C~W#i=HX26VN$SBJWbo>Q;t%T$E z2(qdZ3Y=iO(p>1{mu+k(@x6Nk_>|>hQB1;dju_+Tk#Uwac_(Mg3O|K>NQex4mu`;~<4D6^`)5RlGrXK< z6UgMaxsR*b-ND-XlCvxeVzRn^ysjL1lmg<40TmPNAAWk8eDox5Z{HPHQU|Zi9AxWw zTFLCLQwdw?k2EIuB|IH0z1s}_@+z7S%JU34Wl?CKO5k`>R&v4nm5o1-S5KcCzuF0xb`^)>M;}wMvAz1$B0Z)@ z`z8jHgG|74JJS_Q&2wDhsi3j&kj-nSM$Rw6s*jU+nAS8x|LC!8I6Fg5Pz2JkL$v5m zK;pNN4a)(D^H=lgdwJR4mG8_iB~dzOu$Dm2$5S9v{mJXW2$~}*0hi(*Bb3`PWy=%G zDbLiRc^E!2l8edW(v%L;Fsoh7NB$e0!8l(s?itYWo1gzReTI|wPgJ9+ zdLNrU>B7`0W$^2QYs4aa+%QL{7b=LF5@p#S4(lzFjn5&MF+*#r5u3iIZGK+(FA zYCU>XamXn0N=m_wCE~lyU=;Oz{pv7|1QFw1eWSuz$p&nt2XVIQkCzAKGw% zn!BtZ`BFy(NkgE!JvtbO4Uhz=90TNs{x>F2+d-l>MIulLUo}GjVIvupvPOdq6T+e3%aYgYvQ1G;Mw!t8by0k)^d6)o zR9P*#JEI}S8}3klJDrT|22rsz6s#cns=6>n#KUW@i9{w@{YB<&fC0BiGLh3s2~o*> zdVC4(Ar2jjU)etDG024&JUq5g0`~H@2`LHFi)Ai|E~{@P$cdqfbBxp2IH_T|oVh3Y z)PI;K4%Z&8>ny8(3A&+Ac&6&xb;s84g}urb>TeWPb)B4qW`h;X)h|fW6O^}e92G>Z zj9mvj*B!`l72Tkq(Ze*}9{5tZeFQa*JHZIVtnh{@`?~;>8>m$x!1#+?-?_ zbG)5Xz0~brpxt`J@YeRI-vWc2eL6kz#E_7HIINg_A$4MI1dHWWQ#T3t_mOV{g9O+CB`etUPG zeoceI8aUL#5xf3bQNOd4h&@Rl z1?h0h3+3J4IHj1I!NxTntbvB&y+sN(#Iv4S^FJMs!Ju2 zc#hw??An~bo*-^AXRwIO#1GO#8xQ6Zq<>3?ce3nh$Cno4Ig zGmH?y(?h?(lBD85iycWq&4yzB25S{s&sbM+rYw%h&*?W`{Al(t&Mihr>ApO^Y(|3+ zh|Luj$k;F>LJIzhr!2QsS4=n}m4Ol$`n{IF5(h(6FneMp%hRIiWN=nEOGhb+={PPd z>27Cq8_GDKD5q${)T+(Ry@Y_6EnNe|1tkic#h(QA1{HQKaNnst25k1aHsI`N*+4{V zp2upV>Xedd05)w2x2ix8=t}*I+6_*BE(iSJP2u#gvpC#O+&Q_wg``*t9UV50q~Lsm zA?_~kf!sR9=(W8-S%Q+%!U-(()zhdZ>%p*n3f9lq;KEhDm0}U4Iy!kiR|h*6-C#pi z0rgzn2O*5e7+G0t#;|94q3Gv%%*ORp8#kw-{enn0k??9gMW8PHSAnt)dHCR#AP>2ZBP1G1rznzs+D%TeYD?=YmS=X|XG<%2@CZ11?Q&-Ubi`qb zpeO6tQp|ZU0?$f(N&+C<8$y)R!A7_ad?c<{pqJ&-NSAU6rvt}R1I`zKOY%8)g;Y8Y{90xqMp??)95W)u__n+j=J z!?TD=KO5G8cAu4gwzR64f&7Atni`c{cFp!+g`lXZ+8{eE)IZdc0K4mv>Xq*KXY{&F?z+_Jd2` zp1E5cI@|QX{rO+q z`GnnjHogA~fAE&0KeOUiE?@fe2aevIKj%y?e^4*ye>eEF!`PX-NB!=43Zsi)i{VmS|UT$6x|ug|2yCf^oSf`Mpuw||@7B0$ti4M#kQ90YrYh?`6k zv)oCktQ(R&;AYd#&;7-7PW!vBK7P-pZ(o1n%fI&3=X_{4k9l#)1Mhz3KR)8)PuR2R zbI-kvF*(73c>8@0%4KKn-YuuIX0R$}kPG+sY&z%Z{_l2*(x1Qw9qyn8sTAw)VG!~g zn%=nVnR|BMP`yUb7JHXqEDnA*DjDd5ujz4lgKW_MHeK_uPXgYL0QALAx$VdcZ+nJ- zo)>`p{+R$mndop_ZTjq~SAXc6Kf39Idp3Ra-S7M0TfR4x0QWuh`h_Q7`Wm_};7z{g zhTXfXBf*rMB0H>VUwJz$9+j%yRsBxR^Mn6wn*QOBXTSXT0*bl%J^737{{A_%x#{#* zJ@1d^E`OVlJblyE%btGQ9X`F}x4vB-{x_eErwLI!9apvK^4Fa7?%Qv9FBLsI|D9DA zPB4}CZ2H8@pMCvTPslcMdG5NGuRiXyazB@e8X4gmtDEwykd7Co!XG-u*)GPMrw0ym zy+Qih29uM>mlkM$d{mS3_Qyv(c~$$sVHSz`eA!E$sCv^C{Z;9QQn$RoppCq}V=~#L zhZbn>eO}ac@AICts(lT6s^l}#a-#e_)ihOpaDKzpd#l%$WpQb_GrU@1cz$q{kJ;vA zta5ZsXW4U?@JlhZ=SJrfEKnKMWQ(&eOV7j6c;LVf<+nw9*-Kct%Hh1W> z?_L>X+&1}MY{|v880SyEm@md1zu@Qh2pEjMYQOTKN1@TTD=ucW?fpTE!EGVw1o>fC zAh!=3ICDGegKs)e(&y%?D{t+pzW3Ic;ENSAoHxQ152Bc1`&$v6Dk=8s$M z*{>bFZp}G=<>hOG*uKi;ZocXrgCWb;Zz;H#+gjhwFN%qOnz8!P1TYw+p?$BUAP@qITG7Dc zg(vA&u`yX+C{7uqO>-;83;Llb%mG?StRLY)C;&b107Jt`b3P1!Z_RwL^1|e$30uX* zk2RFU9<&|Y$7-+IZ2V)Ld6F8i)^z-7e4tNsPM&FX)(e{Wc@~dR>bfJD4^i-d9uLU> zF&0LBiYBl&{gh*4qCs(lkFRkqq|?$xA=>N%FkJM^n95c4OZeT=KokrF=m(fliK`8# zO}D8#Rv2CcI|ny|aGSNUR09A+K5It8B5;<1f@0gv=xKM0i~2YQ(X$s} zjfFl&Tx|y{j_UOC@(Spg1QCUTPtS<0sGrB-6~~J}Pb22$Zo86@UW#=8D>pWI(yB>p zS7;1C+H=Kj8{1WXlGDBed0!=G_Auuecu}{a&UM*$#|rs+JaU|NLH=oN#Fhmcc#mi4kcXQcrs$lS1;t}#2-de@?*kVnHqMe^!TCW>D2 z#j@UnD>m_k<#E;+y{D&#w`BP-Ff*&aG*y73xnk2uFF_Xv+kXC~VcPWSZ{U{&Dn)bMnRH;fj@LIu4nW-@zf@RqG=W` zfqQ_cb*v(G0I=WNl96h+HO4(@nfSTfbRCh?STq-~q1#FYjEv|SIq%9xlNam&$&C9F zFr-Yj^;pJ$3RW0S_Mea@=ROraCR>Orinz{_%ltV-W7`+VX4eYeH-K2 zCRj3PCQYr?6=Z0lCJ`cy2m8E0tDQcYBv6)y2Tu1Y*b8L4&g5|L}PR zn3o#^^0c_(g462upJTC)^>0{0;(Wjl;V?~+CNZk3>n~10Db}c$5xnO0t0VTbAZo(g zpmvkDE*bN)hUF$Hx&FH@D84J^yNx0MCS@fYrWl-Hj=n>8116~c8>j)NdytDuovBqm zurQPKAIwW9q2oxotEh%FITl;k&K!K+z*7lW)H8@6d^mu}T$t!y(0DMP`lgxcugiQY zRtM9oCdcVi5dH&JseCgGm=nEgu_$ek=V7Dh6g$Qqhrt|EoALf{hk>oY)G{r zDM$jZCiN-|c$-KqK!N|yuc-eim#FDFRRt^{F+syG};OHIsHvNV(l=YznR=F%INF6+2<0yVZiN9_0&$Sin3zu9&{u=!|7^d z)jbUVD#IR{;#}nJKEut@N~@lipJ{U{;VY#XCBZw=>8c_{D(ojU3rZPvn!BLAw%4sL z9$(a_l(&Fp7*@f0D95?P<@!@fj#G)txl_vBbOMEg@Td4$51v<&s{l~uICW)MFgMez zFg_4?O-3jIbkZzucN67A@%S3(tcx`Ve5w!ivC$w)KlWT1KQABohO6EckR&>XifwDFer=XyL4sNP7aViftFJUC zC-W_@&wX5(Nh4W%^*_>L{Xj3cI&aqTZ*zShEJ5xRVfE%=uh{6~{PNh)38d??BicXD z0#5awuDtpnt(fkUuYS81)3{zlfeqgfBT&orMy$z%ru#`j@OEIOaQZ^~sI4 z$)t%PkeLLplJ`(fRe}fuV8>Ug8hb{`YM7lv-GoqA zL?5&qSP!+A5lP5GH+y{%x|!%Y9xNo)Y%Un-{GG2_Sb#UM#>YZI9f-yp81?5D?*HT! zmwn=!*>jwDUl)JW+koN`9HR{e_t^>oBaKoe%=3F^*ZeRC{?dyNhX-K>eXDWd)U64>NmT_i0X_s9k@{Asr=NKq+D#Ne|e+@E0@8fWCT}U#2>asY-c;f z4lbuIjyvC6yko~>vo(~Qlw9G&r)327XVvw2$QQsPPEo241FLM`_(agZ$E)~f3L_pJn^!!q;;0^)cSgIk z)NeTwOArYP2F^GY3%IuqvlP7dJAI-`ix@n-0e1VVoenq{!DIMR6FcLnZpJ&)hy z`%E*M?^!K9gYW4TOn$Ci=#ArsYW)BdVB(V3KE{Cg)fVY}SOhHsd${7tDkJ36eMg6^*a3a`?5v>#NXE?dEo7L}$G2-(O+8F(EK$(uZn`PHBr9^~Nc!Ptvp~-v) zGdE5!!^x_Wkt-rsw+|TPN0=?uv=FwjRSxXKsSyAh2oiKM3yy~i0|Rw7c9NneN9JK7 zN_-rJRr=O5DGM;!5t1A0$I1gpfhLIRRfhzYHF%$@>i>%m_@vXDzmk42fQjk`PQIFA z9m`MZH%U_^1jNo`Mv*#JAe|QjlAyoMD%ZhRQYK@%$&~pGdc%xK;jY#Mj(?-7)>G@c zY+1`PYHDaLLx}A}X}26^?Q*8v$%xL_H4TxS5FE#GX5cY^P8rf6KTg!&(to-7suit6ox7r4dJ0y1z0*^q(jW@vtF zutRe7OV#Dsr*}F57kCAg{_(?Rh_o3b?(i78C*?wog(;*vxiL+m3&@~jvfWPV=6J7% zNWT$fakQ%sw1;nFHl9S3tG=f=UMd%1>G;g*dp!(8^r-a_+*^Rlw5OCGO+6{Vg)$L? z{6NHu8dN+`7lsSd0=|f#uG|j-2S{7^4583}gZ2|}XFqm}%f4Ec`N#k}X0JoyJpf;*h`>s}^#Lm7Q?ebMp>KieBqykk=~}XLjor)u zkOxvu%VcSPjQ$elDA+R8o@!S&L>wrqE<}j_;G=j(y0tG{au5B~Jey%`kzCZ8+G#h|NATY(lv^IHh}&OqAo6tgcqQysYV;QV>biekR71 zxGl#^g41eXA3*8`zkS-6ylpsbQ;>OY2rp@-w}d4VjixPZ*atXvXllFq5%fZKRvM)R zMCeL>@MgncI2y7(-*W^l-yZm}alaID^L&b;n8f$yq_QyoI0E!4$ZTu<`$P|RAE9~j zk^13e1x>{vmkDu-)X^|}>rllfcsGw)6WVUo%3DJyvvG89zac z8#enF@=yt-uONb9M;;RViJj{wg|F3rsYfOpAQ}^(4Gk7tNj%}`GLp@X--Fi0JJ)X@ zJl6|Ivz!^jE5bhJuJTaIfN>B*jB$iq+l=XIYSY4kCX>&%!Ljv!-tDIt*rmn*kN?nq~8grF06nNm4XqBojcAvqM}jwkXvxd z^5E1(U+Sq~B`qnwjqhT3nInA9TfS$DN5V7cTWc*2Hm87AopYa z&(}=Jc+JV}KrF&|l$J#OzG0lwou|0NOBiX%!k#77^M{?=4w!p@A;d6Xq1GgaOjIor z=Row`n1jc1{+gU)n1V4tAt{kPBp7+!qm#0Nj`OUer*A`1Qzr$@OdW90VqTDUW^4PN z&+v%IS$nH%;BbE4$i_R!AtYr`y9yE2i@i)h-qOx zix*7^_vHEvref88lf>F8CL)jwfNnD723!MOd;7OVA8Z})`|U(*4hfRGI#;bduYpv~ zk2S3JMk4KF9J}tHzQh?&v?&*jx7tnWrslwfX$ABys{3XIfA`N5kBJ4ojo z8rE=v!qjQ=`bOh%)z%T1nvy)Eu292+3h|zdvY0D9fXU$zNtxpW;fkQ9MKXaU*hPQ} z14-!pfPX;mOop#c0DWFk3gN(f*68Dpla=t85g8kDRjSxR)86>nynV-O8DR*6(JnWy z@=>e{17W-OaSy>`J|??NBi9dbr0`Mic%pnS!_>&Hn9rZW!1UsvvI2@|U;Qd>G@XH+ zt9PRia}}fbK1@hHui0}5z+Cp?l2Hwb&l<2S^Yixx;n8QlV{SzvB5A_I@tVaDb)>~DGHMD=LKA$3Eh74rd|&2O9?OcF8hy?a2T zhtj99JH;;0UsectOH&-~<1Gk=Eb9kgFtOVr9%2!thZXL_G0I{DsRS~`+&P7d3=s@Bp$D+s3n?bFst%0$H>^@qWI&*#(Q>Mx~_lbg`RG(+m>w8c6w2-OH~Znk z4~8EFU0hs=*UVkJQz@}N&+NNHODX0~o? zr(6<>j@df49vuiz7Tke^eoda7kEt;~;s{7I8Lw8JmZ76q>xxMIx1y@{7coNApAeJO zf5k=eSM`+m5p5Gg)$S)&?*y$o0zJYwk;OUl&D(wek-CXnhKTv4lEb2vT8r^<8kBS$ zo~|g%BpSve#w&Wg(IqN?3pqf(9@$L8Pur+Tx@6o@73P_eJ7P_6$e3%P7~8cI5fw3ln@FO(x7dKxZMO2;#ZCSn%xjo`ofMDBDBKW~PGO-oVeX_$9> zhTOOJU>cu^wng>$j>HEp2~W(=U5X<+GSb9MOR6FRjZrG`)76u63_5bDkW(qI?kxn3 zc9(1f|EF#vI6VRznH)Vff(LJB7doqxFx&`)O(l}Y7l(Jmi2pxq?sngn!*S6tmsu7Y z|MR=K{~>mBgA&xVslh0T_F=w0`ORXBtNcT?Z zeCZY<<}?7PPp*D7&o4E!3oTA0TRXcln!*D&ig7ISfdkn~oBTQK8?~dnd$uYFS&l-B zL6I?CLUGA2#E><4Qy5(tQZKJwg0z$4ZHZG$X*~QFg>6kxiw=#3b!n!S{YSSnw58u3 zqdkBd^J4d4JE2u|`#{D8&Zp%)7c;!U$!phpu``QFfL15luBxMWtPOD-q@48XWJ(Np zUVj1Jj}}&~W2VFyW0}}qum0l#Ao}i+MM%1HQ>&nVP|g82^ne$mUEUebdHa31o}ATX zgKj@ET`phfldLW3e<-VJZ@=g-jT#iVyYrzuB5Oa&9H;qJ;gMjclwqm{z}43P4T!*2 zw){;@T3*ffc18wMFhQVQ6i6@Z6J@kPVGSgr8qGKmtOmk*DUDm7$|kMqZL!hS7je5< zbh`uZ71N=5hW~`adgptWgp(}+0>V*IqdG0|zbA_dgT~-N6cP>Je_4P-_nb_@1!O05 zJuo{de?uGtM>D5$+3^Z{Zy=kqG$dF}o*J!`1_-1!CKEZZ9626!^haSIw{bRecv)HI zh$%?IEBX=W0~ODDCWO#p&*edGG_*0iuMvf5%fv*CrrXk_71L)m5}LIzWhFRKE;>gu zdmA!RGPn1|5iYTrGlcG%W>S$1MqS3TBfo0s7h?fZr|Pdy*}N!54-Ko(7CGn{N%Ani zqF;pG=-VNdZ?$9EADWxy{k1H2ljKmSPI>koCF7YwG>B{e3N_~#{|g%zpl6d zjRyh588X@Gmt(BJbpcDMKi(Vil^rl`fDVF%1>K4aHPG6e>p4E>&?uD)wNLvxxTqbW zB{HXs#sGN?>62DfM^k%AyQyuS*2g84syegCVV8nJf+b`8cg-)n?%Q{j^ECC7ia7Wz z()(wx+KYNvRWD0g41S%(kliz=-gxYS2A4$7zBZy~L0Yd1ndIfh@KPrwhOVPnf^X6^ zCYG=Fr%zIJdU(;(oi1m%!T8Sxbhd)_^(I%^S;51L#6p(7%{ zWK@vPp(#ATi`G|n5s3A_nFEFGrXjx0VW1F$x!4nb?L>s`<$3C0v6)A!Ht*o?NnYMT z@-{^%r+(4f7OSUy`6H9=b4|g7EH1SYL__CzcjiafTGk$3zX8^vl;=-P9@DLYv3SS> z4+*hU_-Bt{alB`;MT}@v@13J?M(i8)pT3IA{?h}C?-PsGsXhXxdQYI{UT8LlT|qPw z7ze9C&X`Lm0B;Ga&#T*V9BNFm1geKjIB)<3sK}>?rSzWJm<$}IM$mjziDLE|Zs|q` zeX#v}Zzz9tfHfIlRc`@{Xd!YXVp`*4g0`I&%Iw72nL|zS{84e?wjm{$Xu;_ScL|wA z$LK`6`*n&u8R>aaO<;hYrq{I87}LQ-W!$pr z@zDcPFb1<0TUmW(V{*i9XfS)*`pM|2SzBD(p0T}s$3BlCSCaNejOe{e_F<= z7Pv>AQzk?-eF-|y#MupOm6W3zO?5o$3FE^6<-v2Z1b=lF}o53?DepY)_2aROsR z!YbZAP22+}1YNl?#|;-0MF+!9iG2@tL__Epl2_$Bph?GcPcK~DZ4T+f;-1Sq`feuw z{8#uVKYyBU-Vrg2?Mr`1DEG9+{YxkFLFbHh^ARJym|bkz>|%7`8+YOnN!}}PvSJAE zVe#N$8nKp!ExgT>8|VC#6B)L#AGZDB;y0l#&8XRuvIJ)iKMdE_W`-F3-ZpW`X0hRn z^=ug)#ygfuc~t*}`YyLKr2TA4PERB@Bri4sA)g1gv`u>I#E6zrC>||`#BaK7IBi!j zm|c{Vj#;zh!``;7ZGT%c{rs*f9uGP*rhVV5LL-#$Ad3;3A~HW4#ED~Lg=vz$y_pCk zEjd(Y-&4Fynjg+@2urV!5AiibmKb8406@OsrUFMk!KDYNlf(mO_VY)RW9FH-Sa@dX z9?XlJwDfWsc0E;<*fw(Vu22{mpn^~$klcRgFVkR2VgeU+BEP6410}S>kTNFpmAG#J zdcY7UXvS>h|)-lKWW zYcAPaw9K|tFb2&sMnYqyzIGyxNEsk17C#@7FnS*Qt4iSV#iBBYV;*f$P8rx8HSk*>t$XB{^BiGyVG_BDSso=T|NkauS$ z<<%oXFNRWOo#+JqLFcMZu3wMQ@h5qf(7p68U0uF)*!~wneeX5 zo5Q4EmyL}iy1||EtATu&Zy0qdI#f-erXZ=8?y_v#VNON>KtXC(Z^y~*v_jG~#YHSg zMx8kvF0>>b@>xNZqpqn?$_{?bmks)k9v?&o?H~H3M3ik~l?mkz{ijXJ86QzBgEWI% zwoNE>Quh!5<+UCLz!lgsBEEqGUmT02?=8|Y@V#h>Mw>-+)psO*(E1I3H8&7LveNXQ z9SOURb=o|VF@vADv~OG+)elAtzAEKqamWUt2@J%ZvW-QlE_7-dO?;?wBA<4Fw%Ec> z&|uXwEC2|Ato;t|4S!)tc1JHH{H^+o!F zhJle>G6pDdNGuI=a*in#67DZ@ARK_K+7cQH-Ou)oioF1%3!*Tu!YT$7p}w%tJBNL2 zH4tN*gI~}A{h<&35Mc06b~@tN24diWwYoMSIQdJ^xX#y_5pw#hzak>F+7CGFFSqt_n;HT^ab- z)NrQUeMK3f_d}syu{C^M5l%n@Xg|b9Kr#h`V<<8qR-s){9^jqH>7Eaqbi@bPF5Vp& zwtZxO2ew7oUP~qtMI4k@M2;&_&iG2|Jo6Q^ngLCaDe{s#kg8maOjuh@jW|2^f1EsZ zcD9LKMe!BD;2(J%c;H`$hlB*(`ZNYf)%wLE6LjnciYwMYG==0BM3Ug=(csev3-lN% z14x{u5jcWX(~fvsF@BK-ix#>LBMy=BHw6q$p9Z~-soGu-aQqYQ6afMJi#S*u=-CO8 z6?k;);{v-Zk--ROxOH-u>a-~TeoCFRGe&LEzJ#SZc_e@?B_nQ4K@eFje>xc*Ix^4< zpGj9tKv449aRAbK*v6y%!P zUzynO(NvOOysf3;_L#F*T_kIRjuX3adiR244T>n+M_eqQ0UQMdaP^SpoTUNYgY8(m z5gqUi=jyn#c3rk2iFX)?d4mq)ywa=77h`->)e+zaJb<3DYdx!K?tBKEuFFAWZ;f3SB_@& zfcGr2dW5mPnh$i?HM37~WY%R(aH*b^VUDcJzMpGzslvv2i8MwZVGXWESe$GwbR(c( z2N>SVRV0aF_9H8MT&E>XH-%G4Ur#t%a~Um-Y>?{G${KJ0)0|{%wINF|)HDwsD2t7J z0(x1jL$~%l7hsx}qr@Kc6hEYc4RKKQQys0YJd|gBhLk|*+nWj0w)Pc807IkXC5pwh z1XJ^%T2mbWa?G_AxgVw{NLk+#V(5e5Xy%u()1?66oHfJiYteElPA_~`g( zHb!%_?CBh?vg{YIm9{YyGpkU#cFd+~qd2uP9z_kPc06WAL3C%reou6J2WGdmyPG^1sgU}?^*V)a=6Q6EhM`vc&i z#b|#ux(N@&PT<~d@@A4@Wjxnov6B^o;q-&hiaBhi>*y)vsf=mtkx7VuijF>J3uLbN zn+kZX97kbj7nAfREu7H;T{1)iLP3X|T$|;|GGa!ij=V??7BDs~R~fuWIDF{*Y*ex_ zIaz=ix%(;bxO!`SeLPz|g3?eJH48v2?r9pyY$Qv`jd;Q!*IgslXCth!b&aU2plCIM z3;_XDvDuwQw!Ypaw|;fOp(dF;8$(LA2>)YBv_FvR;*j}01`t|>Yct`a4jCY<7A(bW zXRN&##X8w|FgQ@l9*MfW74Oz%D|AP)EPF(IZ+Ir<;q8w_yYNq#}Wqn2JW2!_=cL;{13X zI$4R|y2Y!+)EbthK=Lhp;W6QivGd(t-@N+x#8QuW3|vxD?U=nxa-2I^l_wN$>%t4s zX-DNgvbS|f(fb;*u6!%S%Ww_@Ao!heGKNha-dVlL`8M1ojDNAQ*hmTIG(*~x&lr1% zdzgB{JSJcQmn6;WVr{DW2RJbU`4AAolduoIFX(O4J5P2-Nn9Pkwl%qLgMXqXQEO=W zVUMDbC%-nH9VZ>U!yksj)^+y?NK+2+d+5KF8&fG%m@++Jroi~>@8#{`uxmb%T0nAZ z_j49=vA#n7OmWsy?F+Ye?Y}L>` zml#Ot%FKCtYn*hQm!E0@DsUq;*iG$V1dXM_ZV2KF&fcUlV(_Pia`tPQ&dgt zpe42~<=cayb}UO26Hp*P)P`8`&-$&dKjrleGwMdJyom^>aP+5xnSvLjG#?7gT`y#P zQ@|77PDdTua6)~@AQ?l1xN|fhrP4ITdhJYoABv^T0!SZPwzdnnLqt9c8tFqZqv=Z= z4lyaYJj>dEEGfAY)tP?WO^`gOhgr4(3BYP_lZQi=?RZYTxox-%uzPT7us}T13B6;x zsNM@kIZ-|HdQx)&eGHkoJ|E>w#+^-mY4vnPz4LdNLo2};xr>n5U`3!@JqYwTGdUqsG?V$(z)D;k+t&Jv`@^SGtqEZnhB@s3SLw5+MtrPc*Q?l6jNQu z7w^~_zht)=Ya6;35vITd2PN_{-mz`wIz0n4 z#3L3#lij@YS&lx0_5DKb5}E{J#2WeI{yB7YR_VeFYa^{kF9nK{9rP3igO+u}@le5o zHsc)86rz` zKwG$v#gK(}m1k3MaE^5V^F&k=my_+kq|hEkB41?P7q_H@JG3~%L00#ji7i56^zE_! zB;#raOh^{x6CmvbcY@Vw&U?@@rh!)+%Tcs5Ub!CL&U%GAq-f*ha@bMfG49=w$qb;F z1=0wYUhER8Awrm@0G-Hf{IUDn$&1@*T*X^SGU($1zz;qqY^-G%2-rm^$YXgTLDIs#+>KF8djwNl!&Q zBaaGcA!V!cNtrYg>PEMOB}_kn`dKkL`On%w=3;#KG5r6lg^ZQ_$D4=<&G)H9Lpj#yH$Y z@&%6#^avEE1#WPVoh{JzWv-*G>q}P$U>(rz^-MuoI&pnOiRe>e1I^R4xyg$Ww5D%6 z4_Y)cm_()<$Zz=r?xjtXc4)JEk7vr-gX+2sfGuvkmSNkO-=0G+KwRdaw80c(55ESZKFKkI%fZlQ7~UjBl-p_e3CTU z&Fp73iPij?4Ud>BXj zIWPCrJ%vXluL4#KAe40=O#W`r` zrycYUF=%%w>M5Ks@F3_T^@Gs=l=tm%d@S8Bf)G?Qxd^~yPz?hH->*LyvJMkV=ENu` zhfwu{$*~9dPG+CFi_Qs`<|Yce;aHQ4>es^wL#IAW>MDHEG@EeqP=6DlBfcgb^TW0; zo6S(I${rkZqCYvQ?4W_!8Cd_b6~=KlP33w0;=%YWra3X8FP8PGKePj$iI3E^haG2eHZ1$DbSs4afQp z`Nm$A?cn0}?(~g!)Nrs$;(Qh>K^4_L_=!sFblDz$G0{oU@PjH@Y-4GyFb$<{!=b~V zJ4K+{SvHUext&!g2Z5`l*k~x!3@S+w45J5qy&~yzA%4 zo{RdIk}7p@uJV4S{_k|By)y#AZqA9GbDH9h6NLJsI6lLcQW8 zwkqGaIE6VPMlpGZ5n>ouqzRfz-ZVBXs0GG2$MP`PV#2Zl^6Q*CdB__=e(5G=aZYIK zsJAugUvi3nmxmP_gNq@FljI^r_%wVhOPs9VUp6z1p&$TsW|FE2#xraHOC!=ncA#+! zT0-^(tBN6J{!5@R2L&EOch0I|2BsO`&2U9Oh!UgzP)|#H+P3y$%z#Q>NkFudxT{{~ znX1P7k>M&BKpCbWd`V`g88Ib zIOv0cd)L1t1eqzVl6%v|B@oKamqNJF9_T7x3Riet1F)0AKoC24=s3lX4&c)b{E3Bg z#HSF!Y|%w#VokwnY4#Kb%MKhNHz6LsO*}h`qBPc3;zY(&c93S3 zu0e_nLY87;AN&wIx@#70?}66+W&Q~j}~F@HS7HK z%le~!m|rxHQnth zJA^6w>a{1C1ptv92OuQP#xrE0`eu5z+a= zNIvX7H=bxhW7{gxn3CHew5#?+jNGi}X#|raq=c3oc@>#^%Bl_|Lr{VWQY&&mBoNRz zPeFJ;(au!z<@T%J?&UKG9)+%X2IaAxS g%mUIj&1Syx;d-_- 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

Bjv{;8EQ=DrVpkQw8BJr2nlUz42IP* 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

>#=fOmo1#H00ZXN?z%1w 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 z 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 diff --git a/src/App.jsx b/src/App.jsx index f900c79..4039888 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,83 +1,68 @@ -import { useState, useEffect } from 'react' -import { Analytics } from '@vercel/analytics/react' -import { useFeatureDetection } from './hooks/useFeatureDetection.js' -import { useKoboStore } from './stores/koboStore.js' +import { Analytics } from '@vercel/analytics/react'; +import { useFeatureDetection } from './hooks/useFeatureDetection.ts'; +import { useKoboStore } from './stores/koboStore.ts'; // Layout -import { Header } from './components/layout/Header.jsx' -import { Footer } from './components/layout/Footer.jsx' +import { Header } from './components/layout/Header.jsx'; +import { Footer } from './components/layout/Footer.jsx'; // Browser compatibility -import { UnsupportedBrowser } from './components/UnsupportedBrowser.jsx' -import { BrowserWarning } from './components/BrowserWarning.jsx' +import { UnsupportedBrowser } from './components/UnsupportedBrowser.jsx'; +import { BrowserWarning } from './components/BrowserWarning.jsx'; // Analytics banner -import { AnalyticsBanner } from './components/AnalyticsBanner.jsx' +import { AnalyticsBanner } from './components/AnalyticsBanner.jsx'; // Pages -import { Home } from './pages/Home.jsx' -import { Backup } from './pages/Backup.jsx' -import { Restore } from './pages/Restore.jsx' -import { History } from './pages/History.jsx' -import { UserGuide } from './pages/UserGuide.jsx' -import { FAQ } from './pages/FAQ.jsx' -import { PrivacyPolicy } from './pages/PrivacyPolicy.jsx' -import { LibraryDashboard } from './pages/LibraryDashboard.jsx' +import { Home } from './pages/Home.jsx'; +import { Backup } from './pages/Backup.jsx'; +import { Restore } from './pages/Restore.jsx'; +import { History } from './pages/History.jsx'; +import { UserGuide } from './pages/UserGuide.jsx'; +import { FAQ } from './pages/FAQ.jsx'; +import { PrivacyPolicy } from './pages/PrivacyPolicy.jsx'; +import { LibraryDashboard } from './pages/LibraryDashboard.jsx'; function App() { - const features = useFeatureDetection() - const currentPage = useKoboStore((state) => state.currentPage) - const setCurrentPage = useKoboStore((state) => state.setCurrentPage) - - // Browser compatibility check - if (features.loading) { - return ( -

-
-
-

Loading...

-
-
- ) - } + const features = useFeatureDetection(); + const currentPage = useKoboStore((state) => state.currentPage); + const setCurrentPage = useKoboStore((state) => state.setCurrentPage); // Show error page for completely unsupported browsers if (!features.isSupported) { - const missingFeatures = [] - if (!features.fileReader) missingFeatures.push('FileReader API') - if (!features.webAssembly) missingFeatures.push('WebAssembly') - if (!features.blob) missingFeatures.push('Blob API') + const missingFeatures = []; + if (!features.webAssembly) missingFeatures.push('WebAssembly'); - return + return ; } const handleNavigate = (page) => { - setCurrentPage(page) - window.scrollTo({ top: 0, behavior: 'smooth' }) - } + setCurrentPage(page); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; const renderPage = () => { switch (currentPage) { case 'home': - return + return ; case 'dashboard': - return + return ; case 'backup': - return handleNavigate('home')} /> + return handleNavigate('home')} />; case 'restore': - return handleNavigate('home')} /> + return handleNavigate('home')} />; case 'history': - return + return ; case 'guide': - return + return ; case 'faq': - return + return ; case 'privacy': - return + return ; default: - return + return ; } - } + }; return (
@@ -88,9 +73,7 @@ function App() {
{/* Main content */} -
- {renderPage()} -
+
{renderPage()}
{/* Footer */}
@@ -101,7 +84,7 @@ function App() { {/* Vercel Analytics - cookieless, privacy-preserving */}
- ) + ); } -export default App +export default App; diff --git a/src/components/AnalyticsBanner.jsx b/src/components/AnalyticsBanner.jsx index 67011c7..b5386a2 100644 --- a/src/components/AnalyticsBanner.jsx +++ b/src/components/AnalyticsBanner.jsx @@ -3,20 +3,12 @@ * Informs the user that only Vercel Analytics (cookieless) is used. */ -import { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; +import { useState } from 'react'; const STORAGE_KEY = 'analytics_banner_dismissed'; export function AnalyticsBanner({ onNavigate }) { - const [visible, setVisible] = useState(false); - - useEffect(() => { - const dismissed = localStorage.getItem(STORAGE_KEY); - if (!dismissed) { - setVisible(true); - } - }, []); + const [visible, setVisible] = useState(() => !localStorage.getItem(STORAGE_KEY)); const dismiss = () => { localStorage.setItem(STORAGE_KEY, '1'); @@ -29,11 +21,13 @@ export function AnalyticsBanner({ onNavigate }) {

- This site uses{' '} - Vercel Analytics — a cookieless, privacy-preserving service that collects - only anonymous page-view counts. No personal data, no cookies, no tracking across sites.{' '} + This site uses Vercel Analytics — a cookieless, privacy-preserving service that + collects only anonymous page-view counts. No personal data, no cookies, no tracking across sites.{' '} @@ -49,7 +43,3 @@ export function AnalyticsBanner({ onNavigate }) {

); } - -AnalyticsBanner.propTypes = { - onNavigate: PropTypes.func.isRequired, -}; diff --git a/src/components/BrowserWarning.jsx b/src/components/BrowserWarning.jsx index 824ef6a..920631a 100644 --- a/src/components/BrowserWarning.jsx +++ b/src/components/BrowserWarning.jsx @@ -3,8 +3,8 @@ */ import { useState } from 'react'; -import { StatusBadge } from './common/StatusBadge.jsx'; -import { Button } from './common/Button.jsx'; +import { StatusBadge } from './common/StatusBadge.tsx'; +import { Button } from './common/Button.tsx'; export function BrowserWarning({ features }) { const [dismissed, setDismissed] = useState(false); @@ -17,13 +17,8 @@ export function BrowserWarning({ features }) {
-
- +
+
-

- Limited Browser Support -

+

Limited Browser Support

- Your browser has limited support for this application. For the best - experience, we recommend using Google Chrome 86+, Microsoft Edge 86+, or - Opera 72+. + Your browser has limited support for this application. For the best experience, we recommend + using Google Chrome 86+, Microsoft Edge 86+, or Opera 72+.

- - Slower backup process - - - Manual file selection required - + Slower backup process + Manual file selection required
@@ -74,21 +63,11 @@ export function BrowserWarning({ features }) {
diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx index 2b7d5ce..5e50f27 100644 --- a/src/components/ErrorBoundary.jsx +++ b/src/components/ErrorBoundary.jsx @@ -3,9 +3,9 @@ */ import { Component } from 'react'; -import { Card } from './common/Card.jsx'; -import { Button } from './common/Button.jsx'; -import { Icon } from './common/Icon.jsx'; +import { Card } from './common/Card.tsx'; +import { Button } from './common/Button.tsx'; +import { Icon } from './common/Icon.tsx'; export class ErrorBoundary extends Component { constructor(props) { @@ -17,7 +17,7 @@ export class ErrorBoundary extends Component { }; } - static getDerivedStateFromError(error) { + static getDerivedStateFromError() { // Update state so the next render will show the fallback UI return { hasError: true }; } @@ -88,7 +88,7 @@ export class ErrorBoundary extends Component { Error: {this.state.error.toString()}

{this.state.errorInfo && ( -
+                          
                             {this.state.errorInfo.componentStack}
                           
)} @@ -129,18 +129,10 @@ export class ErrorBoundary extends Component { {/* Action Buttons */}
- - diff --git a/src/components/UnsupportedBrowser.jsx b/src/components/UnsupportedBrowser.jsx index c8f78b0..ddadaf5 100644 --- a/src/components/UnsupportedBrowser.jsx +++ b/src/components/UnsupportedBrowser.jsx @@ -2,8 +2,7 @@ * Full-page error for completely unsupported browsers */ -import { Card } from './common/Card.jsx'; -import { Button } from './common/Button.jsx'; +import { Card } from './common/Card.tsx'; export function UnsupportedBrowser({ missingFeatures = [] }) { const recommendedBrowsers = [ @@ -33,12 +32,7 @@ export function UnsupportedBrowser({ missingFeatures = [] }) {
{/* Error Icon */}
- +
-

- Browser Not Supported -

+

Browser Not Supported

- Unfortunately, your current browser doesn't support the technologies - required to run Kobo Backup Manager. + Unfortunately, your current browser doesn't support the technologies required to run Kobo Backup + Manager.

{/* Missing Features */} {missingFeatures.length > 0 && (
diff --git a/src/components/backup/BackupConfiguration.jsx b/src/components/backup/BackupConfiguration.jsx index 8e76dd9..6d60a15 100644 --- a/src/components/backup/BackupConfiguration.jsx +++ b/src/components/backup/BackupConfiguration.jsx @@ -2,11 +2,11 @@ * Backup configuration - choose what to include */ -import { Card } from '../common/Card.jsx'; -import { Button } from '../common/Button.jsx'; -import { Checkbox } from '../common/Checkbox.jsx'; -import { Icon } from '../common/Icon.jsx'; -import { formatBytes } from '../../utils/formatters.js'; +import { Card } from '../common/Card.tsx'; +import { Button } from '../common/Button.tsx'; +import { Checkbox } from '../common/Checkbox.tsx'; +import { Icon } from '../common/Icon.tsx'; +import { formatBytes } from '../../utils/formatters.ts'; export function BackupConfiguration({ options, @@ -19,9 +19,7 @@ export function BackupConfiguration({ return (
-

- Backup Configuration -

+

Backup Configuration

Choose what to include in your backup

@@ -59,7 +57,7 @@ export function BackupConfiguration({ {/* Storage Estimate */}
- +

Estimated backup size: {formatBytes(estimatedSize)} @@ -72,12 +70,7 @@ export function BackupConfiguration({

{/* Start Button */} - diff --git a/src/components/backup/BackupProgress.jsx b/src/components/backup/BackupProgress.jsx index 5d4fbd6..537ff34 100644 --- a/src/components/backup/BackupProgress.jsx +++ b/src/components/backup/BackupProgress.jsx @@ -2,11 +2,10 @@ * Backup progress screen with circular progress */ -import PropTypes from 'prop-types'; -import { Card } from '../common/Card.jsx'; -import { CircularProgress } from '../common/CircularProgress.jsx'; -import { Icon } from '../common/Icon.jsx'; -import { Button } from '../common/Button.jsx'; +import { Card } from '../common/Card.tsx'; +import { CircularProgress } from '../common/CircularProgress.tsx'; +import { Icon } from '../common/Icon.tsx'; +import { Button } from '../common/Button.tsx'; export function BackupProgress({ progress, error, onRetry }) { const { stage, percent, filesProcessed, totalFiles } = progress; @@ -22,19 +21,15 @@ export function BackupProgress({ progress, error, onRetry }) {

Backup Failed

-

- The backup could not be completed. Error details: -

+

The backup could not be completed. Error details:

-

{error}

+

{error}

- If you have a large library (>2 GB), try closing other browser tabs to free memory, - then retry. Open the browser console (F12) for more details. + If you have a large library (>2 GB), try closing other browser tabs to free memory, then retry. + Open the browser console (F12) for more details.

- +
); @@ -43,9 +38,7 @@ export function BackupProgress({ progress, error, onRetry }) { return (
-

- Creating Backup -

+

Creating Backup

{/* Circular Progress */}
@@ -53,9 +46,7 @@ export function BackupProgress({ progress, error, onRetry }) {
{/* Current Stage */} -

- {stage || 'Processing...'} -

+

{stage || 'Processing...'}

{/* File counter */} {filesProcessed !== undefined && totalFiles > 0 && ( @@ -90,7 +81,7 @@ export function BackupProgress({ progress, error, onRetry }) { {/* Warning */}
- +

Keep this window open until the backup is complete

@@ -101,32 +92,16 @@ export function BackupProgress({ progress, error, onRetry }) { ); } -BackupProgress.propTypes = { - progress: PropTypes.object.isRequired, - error: PropTypes.string, - onRetry: PropTypes.func, -}; - function StageItem({ text, status }) { - const icons = { - completed: 'check', - active: 'loading', - pending: 'loading', - }; - - const colors = { - completed: 'text-kobo-success', - active: 'text-kobo-accent', - pending: 'text-kobo-gray-light', - }; - return (
{status === 'completed' && } {status === 'active' && (
)} - {status === 'pending' &&
} + {status === 'pending' && ( +
+ )} {text}
); diff --git a/src/components/backup/BackupSuccess.jsx b/src/components/backup/BackupSuccess.jsx index 3babcd8..1c82fb3 100644 --- a/src/components/backup/BackupSuccess.jsx +++ b/src/components/backup/BackupSuccess.jsx @@ -2,10 +2,10 @@ * Backup success screen */ -import { Card } from '../common/Card.jsx'; -import { Button } from '../common/Button.jsx'; -import { Icon } from '../common/Icon.jsx'; -import { formatBytes, formatDate } from '../../utils/formatters.js'; +import { Card } from '../common/Card.tsx'; +import { Button } from '../common/Button.tsx'; +import { Icon } from '../common/Icon.tsx'; +import { formatBytes } from '../../utils/formatters.ts'; export function BackupSuccess({ result, onCreateAnother, onDone }) { return ( @@ -16,9 +16,7 @@ export function BackupSuccess({ result, onCreateAnother, onDone }) {
-

- Backup Complete! -

+

Backup Complete!

Your Kobo library has been successfully backed up @@ -26,22 +24,9 @@ export function BackupSuccess({ result, onCreateAnother, onDone }) { {/* Summary Cards */}

- - - + + +
- +

Backup saved to your Downloads folder

-

- Look for: {result.filename} -

+

Look for: {result.filename}

@@ -89,7 +72,9 @@ export function BackupSuccess({ result, onCreateAnother, onDone }) { 🔒

Store in multiple locations

-

3-2-1 backup rule: 3 copies, 2 different media, 1 offsite

+

+ 3-2-1 backup rule: 3 copies, 2 different media, 1 offsite +

@@ -97,18 +82,10 @@ export function BackupSuccess({ result, onCreateAnother, onDone }) { {/* Action Buttons */}
- -
@@ -122,9 +99,7 @@ function SummaryCard({ icon, label, value, truncate }) {

{label}

-

- {value} -

+

{value}

); } diff --git a/src/components/backup/BackupWizard.jsx b/src/components/backup/BackupWizard.jsx index 293bd74..da4cb1e 100644 --- a/src/components/backup/BackupWizard.jsx +++ b/src/components/backup/BackupWizard.jsx @@ -3,12 +3,12 @@ */ import { useState } from 'react'; -import { useFileSystem } from '../../hooks/useFileSystem.js'; -import { useKoboDevice } from '../../hooks/useKoboDevice.js'; -import { useBackup } from '../../hooks/useBackup.js'; -import { useKoboStore } from '../../stores/koboStore.js'; -import { generateBackupFilename } from '../../utils/backup.js'; -import { Container } from '../layout/Container.jsx'; +import { useFileSystem } from '../../hooks/useFileSystem.ts'; +import { useKoboDevice } from '../../hooks/useKoboDevice.ts'; +import { useBackup } from '../../hooks/useBackup.ts'; +import { useKoboStore } from '../../stores/koboStore.ts'; +import { generateBackupFilename } from '../../utils/backup.ts'; +import { Container } from '../layout/Container.tsx'; import { DeviceSelector } from './DeviceSelector.jsx'; import { ScanningScreen } from './ScanningScreen.jsx'; @@ -31,7 +31,7 @@ export function BackupWizard({ onComplete }) { const koboDevice = useKoboDevice(); const backup = useBackup(); const addBackup = useKoboStore((state) => state.addBackup); - + const setDevice = useKoboStore((state) => state.setDevice); const setBooks = useKoboStore((state) => state.setBooks); const setAnnotations = useKoboStore((state) => state.setAnnotations); @@ -48,7 +48,7 @@ export function BackupWizard({ onComplete }) { try { const data = await koboDevice.scanDevice(dirHandle); setKoboData(data); - + // Populate global store setDevice(data.deviceInfo); setBooks(data.books); @@ -162,9 +162,7 @@ export function BackupWizard({ onComplete }) { /> )} - {step === 'scanning' && ( - - )} + {step === 'scanning' && } {step === 'overview' && koboData && ( + )} {step === 'success' && backup.result && ( - + )}
diff --git a/src/components/backup/CoverPreview.jsx b/src/components/backup/CoverPreview.jsx index 3f767fd..287e02a 100644 --- a/src/components/backup/CoverPreview.jsx +++ b/src/components/backup/CoverPreview.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { getCoverUrl } from '../../utils/koboCovers.js'; +import { getCoverUrl } from '../../utils/koboCovers.ts'; // Simple hash function to generate consistent color palettes per book title function getBookColors(title = '') { @@ -7,11 +7,11 @@ function getBookColors(title = '') { for (let i = 0; i < title.length; i++) { hash = title.charCodeAt(i) + ((hash << 5) - hash); } - + const hue = Math.abs(hash % 360); const saturation = 45 + Math.abs((hash >> 8) % 25); // 45% - 70% const lightness = 25 + Math.abs((hash >> 16) % 20); // 25% - 45% (sleek dark colors) - + return { bg: `hsl(${hue}, ${saturation}%, ${lightness}%)`, bgLight: `hsl(${hue}, ${saturation}%, ${lightness + 15}%)`, @@ -20,7 +20,13 @@ function getBookColors(title = '') { }; } -export function CoverPreview({ deviceHandle, coverId, title = 'Untitled', author = 'Unknown', className = '' }) { +export function CoverPreview({ + deviceHandle, + coverId, + title = 'Untitled', + author = 'Unknown', + className = '', +}) { const [coverUrl, setCoverUrl] = useState(null); const [loading, setLoading] = useState(true); const colors = getBookColors(title); @@ -34,7 +40,7 @@ export function CoverPreview({ deviceHandle, coverId, title = 'Untitled', author setLoading(false); return; } - + try { url = await getCoverUrl(deviceHandle, coverId); if (active) { @@ -62,7 +68,7 @@ export function CoverPreview({ deviceHandle, coverId, title = 'Untitled', author }; }, [deviceHandle, coverId]); - const wrapperClass = `relative aspect-[3/4] rounded-md shadow-md overflow-hidden transition-all duration-300 group-hover:shadow-lg group-hover:scale-[1.03] select-none ${className}`; + const wrapperClass = `relative aspect-3/4 rounded-md shadow-md overflow-hidden transition-all duration-300 group-hover:shadow-lg group-hover:scale-[1.03] select-none ${className}`; if (coverUrl && !loading) { return ( @@ -70,14 +76,9 @@ export function CoverPreview({ deviceHandle, coverId, title = 'Untitled', author {/* Real book spine effect */}
- {title} + {title} {/* Soft overlay shadow to look like a book */} -
+
); } @@ -87,39 +88,39 @@ export function CoverPreview({ deviceHandle, coverId, title = 'Untitled', author
{/* Book spine elements */}
- +
{/* Decorative elements */}
- + {/* Typography */} -
+

{title}

- {author}
- + {/* Bottom decorative bar */}
Kobo Edition ●
- + {/* Real book shadow overlay */} -
+
); } diff --git a/src/components/backup/DeviceSelector.jsx b/src/components/backup/DeviceSelector.jsx index 0ba865e..8bc5b38 100644 --- a/src/components/backup/DeviceSelector.jsx +++ b/src/components/backup/DeviceSelector.jsx @@ -2,9 +2,9 @@ * Device selector component with connection instructions */ -import { Card } from '../common/Card.jsx'; -import { Button } from '../common/Button.jsx'; -import { Icon } from '../common/Icon.jsx'; +import { Card } from '../common/Card.tsx'; +import { Button } from '../common/Button.tsx'; +import { Icon } from '../common/Icon.tsx'; export function DeviceSelector({ onSelectDevice, isSelecting, error }) { return ( @@ -14,12 +14,8 @@ export function DeviceSelector({ onSelectDevice, isSelecting, error }) {
-

- Connect Your Kobo -

-

- Follow these steps to connect your device -

+

Connect Your Kobo

+

Follow these steps to connect your device

{/* Connection Steps */} @@ -28,9 +24,7 @@ export function DeviceSelector({ onSelectDevice, isSelecting, error }) {
1
-

- Plug in USB -

+

Plug in USB

Connect your Kobo to your computer using the USB cable

@@ -40,9 +34,7 @@ export function DeviceSelector({ onSelectDevice, isSelecting, error }) {
2
-

- Unlock Device -

+

Unlock Device

Tap "Connect" on your Kobo screen when prompted

@@ -52,9 +44,7 @@ export function DeviceSelector({ onSelectDevice, isSelecting, error }) {
3
-

- Select Device -

+

Select Device

Click the button below to select your Kobo drive

@@ -65,14 +55,10 @@ export function DeviceSelector({ onSelectDevice, isSelecting, error }) { {error && (
- +
-

- {error.title} -

-

- {error.message} -

+

{error.title}

+

{error.message}

@@ -92,9 +78,7 @@ export function DeviceSelector({ onSelectDevice, isSelecting, error }) { {/* Help Text */}
-

- Your Kobo should appear as a drive named "KOBOeReader" -

+

Your Kobo should appear as a drive named "KOBOeReader"

diff --git a/src/components/backup/LibraryOverview.jsx b/src/components/backup/LibraryOverview.jsx index cbfcf4b..15d5041 100644 --- a/src/components/backup/LibraryOverview.jsx +++ b/src/components/backup/LibraryOverview.jsx @@ -2,21 +2,17 @@ * Library overview showing statistics after scan */ -import { Card } from '../common/Card.jsx'; -import { Button } from '../common/Button.jsx'; -import { Icon } from '../common/Icon.jsx'; -import { formatBytes, formatDuration } from '../../utils/formatters.js'; +import { Card } from '../common/Card.tsx'; +import { Button } from '../common/Button.tsx'; +import { Icon } from '../common/Icon.tsx'; +import { formatBytes, formatDuration } from '../../utils/formatters.ts'; export function LibraryOverview({ books, annotations, stats, estimatedSize, onContinue }) { return (
-

- Your Library -

-

- Here's what we found on your Kobo -

+

Your Library

+

Here's what we found on your Kobo

{/* Statistics Cards */} @@ -33,44 +29,26 @@ export function LibraryOverview({ books, annotations, stats, estimatedSize, onCo label="Annotations" color="green" /> - - + +
{/* Reading Stats */} {stats && ( -

- Reading Statistics -

+

Reading Statistics

Books Started

-

- {stats.booksStarted} -

+

{stats.booksStarted}

Currently Reading

-

- {stats.currentlyReading} -

+

{stats.currentlyReading}

Time Spent Reading

-

- {formatDuration(stats.totalMinutesRead)} -

+

{formatDuration(stats.totalMinutesRead)}

@@ -79,9 +57,7 @@ export function LibraryOverview({ books, annotations, stats, estimatedSize, onCo {/* Recently Read Books Preview */} {books && books.length > 0 && ( -

- Recently Read Books -

+

Recently Read Books

{books.slice(0, 5).map((book, index) => (
-

- {book.Title} -

-

- {book.Author} -

+

{book.Title}

+

{book.Author}

-
- {book.Progress}% -
+
{book.Progress}%
))} @@ -114,12 +84,7 @@ export function LibraryOverview({ books, annotations, stats, estimatedSize, onCo {/* Action Buttons */}
- @@ -143,12 +108,8 @@ function StatCard({ icon, value, label, color }) {
-
- {value} -
-
- {label} -
+
{value}
+
{label}
); } diff --git a/src/components/backup/ScanningScreen.jsx b/src/components/backup/ScanningScreen.jsx index b58ab6f..660691e 100644 --- a/src/components/backup/ScanningScreen.jsx +++ b/src/components/backup/ScanningScreen.jsx @@ -2,9 +2,9 @@ * Scanning screen showing progress while reading Kobo device */ -import { Card } from '../common/Card.jsx'; -import { ProgressBar } from '../common/ProgressBar.jsx'; -import { Icon } from '../common/Icon.jsx'; +import { Card } from '../common/Card.tsx'; +import { ProgressBar } from '../common/ProgressBar.tsx'; +import { Icon } from '../common/Icon.tsx'; export function ScanningScreen({ scanProgress }) { const { stage, current, total } = scanProgress; @@ -23,43 +23,29 @@ export function ScanningScreen({ scanProgress }) {
-

- Scanning Your Kobo -

-

- {stage || 'Preparing...'} -

+

Scanning Your Kobo

+

{stage || 'Preparing...'}

{/* Progress Bar */} - + {/* Status Details */}
- + = 1 ? "completed" : current === 0 ? "pending" : "active"} + status={current >= 1 ? 'completed' : current === 0 ? 'pending' : 'active'} /> = 2 ? "completed" : current < 1 ? "pending" : "active"} + status={current >= 2 ? 'completed' : current < 1 ? 'pending' : 'active'} /> = 3 ? "completed" : current < 2 ? "pending" : "active"} - /> - = 4 ? "completed" : "pending"} + status={current >= 3 ? 'completed' : current < 2 ? 'pending' : 'active'} /> + = 4 ? 'completed' : 'pending'} />
{/* Info Message */} @@ -73,7 +59,7 @@ export function ScanningScreen({ scanProgress }) { ); } -function StatusItem({ icon, text, status }) { +function StatusItem({ text, status }) { const statusColors = { completed: 'bg-kobo-success', active: 'bg-kobo-accent', diff --git a/src/components/common/Button.jsx b/src/components/common/Button.jsx deleted file mode 100644 index cd68701..0000000 --- a/src/components/common/Button.jsx +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Button component with Kobo design system variants - */ - -import PropTypes from 'prop-types'; - -export function Button({ - children, - variant = 'primary', - size = 'md', - disabled = false, - loading = false, - onClick, - type = 'button', - className = '', - ...props -}) { - const baseStyles = 'font-semibold rounded-lg transition-all-smooth focus-visible-ring disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2'; - - const variants = { - primary: 'bg-kobo-accent hover:bg-kobo-accent-dark text-white', - secondary: 'bg-kobo-cream-dark hover:bg-kobo-gray-light/10 text-kobo-dark border-2 border-kobo-gray-light transition-colors', - ghost: 'text-kobo-accent hover:bg-kobo-accent/10 transition-colors', - danger: 'bg-kobo-error hover:bg-kobo-error/90 text-white', - }; - - const sizes = { - sm: 'px-3 py-1.5 text-sm', - md: 'px-4 py-2 text-base', - lg: 'px-6 py-3 text-lg', - }; - - const variantClass = variants[variant] || variants.primary; - const sizeClass = sizes[size] || sizes.md; - - return ( - - ); -} - -Button.propTypes = { - children: PropTypes.node.isRequired, - variant: PropTypes.oneOf(['primary', 'secondary', 'ghost', 'danger']), - size: PropTypes.oneOf(['sm', 'md', 'lg']), - disabled: PropTypes.bool, - loading: PropTypes.bool, - onClick: PropTypes.func, - type: PropTypes.oneOf(['button', 'submit', 'reset']), - className: PropTypes.string, -}; diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx new file mode 100644 index 0000000..4859653 --- /dev/null +++ b/src/components/common/Button.tsx @@ -0,0 +1,52 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'outline'; +export type ButtonSize = 'sm' | 'md' | 'lg'; + +export interface ButtonProps extends ButtonHTMLAttributes { + children: ReactNode; + variant?: ButtonVariant; + size?: ButtonSize; + loading?: boolean; +} + +const BASE = + 'font-semibold rounded-lg transition-all-smooth focus-visible-ring disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2'; + +const VARIANTS: Record = { + primary: 'bg-kobo-accent hover:bg-kobo-accent-dark text-kobo-dark', + secondary: 'bg-kobo-cream-dark hover:bg-kobo-gray-light/10 text-kobo-dark border-2 border-kobo-gray-light', + outline: 'bg-white hover:bg-kobo-cream text-kobo-dark border-2 border-kobo-accent', + ghost: 'text-kobo-dark hover:bg-kobo-accent/15', + danger: 'bg-kobo-error hover:bg-kobo-error/90 text-white', +}; + +const SIZES: Record = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2 text-base', + lg: 'px-6 py-3 text-lg', +}; + +export function Button({ + children, + variant = 'primary', + size = 'md', + disabled = false, + loading = false, + type = 'button', + className = '', + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/src/components/common/Card.jsx b/src/components/common/Card.jsx deleted file mode 100644 index fedaf39..0000000 --- a/src/components/common/Card.jsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Card component with elevation and glass variants - */ - -import PropTypes from 'prop-types'; - -export function Card({ - children, - variant = 'elevated', - gradient = false, - className = '', - ...props -}) { - const baseStyles = 'rounded-2xl p-6 relative overflow-hidden'; - - const variants = { - elevated: 'bg-white shadow-elevated', - glass: 'bg-kobo-glass backdrop-blur-sm', - flat: 'bg-kobo-cream-dark', - }; - - const variantClass = variants[variant] || variants.elevated; - - return ( -
- {gradient && ( -
- )} -
- {children} -
-
- ); -} - -Card.propTypes = { - children: PropTypes.node.isRequired, - variant: PropTypes.oneOf(['elevated', 'glass', 'flat']), - gradient: PropTypes.bool, - className: PropTypes.string, -}; diff --git a/src/components/common/Card.tsx b/src/components/common/Card.tsx new file mode 100644 index 0000000..f93856c --- /dev/null +++ b/src/components/common/Card.tsx @@ -0,0 +1,30 @@ +import type { HTMLAttributes, ReactNode } from 'react'; + +export interface CardProps extends HTMLAttributes { + children: ReactNode; + variant?: 'elevated' | 'glass' | 'flat'; + gradient?: boolean; +} + +const VARIANTS = { + elevated: 'bg-white shadow-elevated', + glass: 'bg-kobo-glass backdrop-blur-xs', + flat: 'bg-kobo-cream-dark', +}; + +export function Card({ + children, + variant = 'elevated', + gradient = false, + className = '', + ...props +}: CardProps) { + return ( +
+ {gradient && ( +
+ )} +
{children}
+
+ ); +} diff --git a/src/components/common/Checkbox.jsx b/src/components/common/Checkbox.jsx deleted file mode 100644 index fe83051..0000000 --- a/src/components/common/Checkbox.jsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Custom styled checkbox component - */ - -import PropTypes from 'prop-types'; - -export function Checkbox({ - checked = false, - onChange, - label = null, - sublabel = null, - disabled = false, - className = '', - ...props -}) { - return ( - - ); -} - -Checkbox.propTypes = { - checked: PropTypes.bool, - onChange: PropTypes.func, - label: PropTypes.node, - sublabel: PropTypes.node, - disabled: PropTypes.bool, - className: PropTypes.string, -}; diff --git a/src/components/common/Checkbox.tsx b/src/components/common/Checkbox.tsx new file mode 100644 index 0000000..362f1bb --- /dev/null +++ b/src/components/common/Checkbox.tsx @@ -0,0 +1,67 @@ +import { useId, type ReactNode } from 'react'; + +export interface CheckboxProps { + checked?: boolean; + onChange?: (checked: boolean) => void; + label?: ReactNode; + sublabel?: ReactNode; + disabled?: boolean; + className?: string; +} + +export function Checkbox({ + checked = false, + onChange, + label, + sublabel, + disabled = false, + className = '', +}: CheckboxProps) { + const descriptionId = useId(); + return ( + + ); +} diff --git a/src/components/common/CircularProgress.jsx b/src/components/common/CircularProgress.jsx deleted file mode 100644 index 5804161..0000000 --- a/src/components/common/CircularProgress.jsx +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Circular progress indicator (SVG-based) - */ - -import PropTypes from 'prop-types'; - -export function CircularProgress({ - percent = 0, - size = 200, - strokeWidth = 12, - showLabel = true, - className = '', -}) { - const normalizedPercent = Math.min(100, Math.max(0, percent)); - - const radius = (size - strokeWidth) / 2; - const circumference = radius * 2 * Math.PI; - const offset = circumference - (normalizedPercent / 100) * circumference; - - return ( -
- - {/* Background circle */} - - - {/* Progress circle */} - - - - {showLabel && ( -
- - {Math.round(normalizedPercent)}% - -
- )} -
- ); -} - -CircularProgress.propTypes = { - percent: PropTypes.number, - size: PropTypes.number, - strokeWidth: PropTypes.number, - showLabel: PropTypes.bool, - className: PropTypes.string, -}; diff --git a/src/components/common/CircularProgress.tsx b/src/components/common/CircularProgress.tsx new file mode 100644 index 0000000..df4bbdd --- /dev/null +++ b/src/components/common/CircularProgress.tsx @@ -0,0 +1,63 @@ +export interface CircularProgressProps { + percent?: number; + size?: number; + strokeWidth?: number; + showLabel?: boolean; + label?: string; + className?: string; +} + +export function CircularProgress({ + percent = 0, + size = 200, + strokeWidth = 12, + showLabel = true, + label, + className = '', +}: CircularProgressProps) { + const value = Math.min(100, Math.max(0, percent)); + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const offset = circumference - (value / 100) * circumference; + + return ( +
+ + + {showLabel && ( + + {Math.round(value)}% + + )} +
+ ); +} diff --git a/src/components/common/Icon.jsx b/src/components/common/Icon.jsx deleted file mode 100644 index 851528c..0000000 --- a/src/components/common/Icon.jsx +++ /dev/null @@ -1,271 +0,0 @@ -/** - * SVG icon component library - */ - -import PropTypes from 'prop-types'; - -const icons = { - book: ( - - ), - note: ( - - ), - usb: ( - - ), - folder: ( - - ), - check: ( - - ), - alert: ( - - ), - x: ( - - ), - download: ( - - ), - upload: ( - - ), - storage: ( - - ), - device: ( - - ), - chart: ( - - ), - info: ( - - ), - loading: ( - - ), - eject: ( - - ), - disconnect: ( - - ), - file: ( - - ), - restore: ( - - ), - delete: ( - - ), - bookmark: ( - - ), - history: ( - - ), - backup: ( - - ), - calendar: ( - - ), - database: ( - - ), - trash: ( - - ), - refresh: ( - - ), - search: ( - - ), -}; - -export function Icon({ - type, - size = 'md', - className = '', - style, - ...props -}) { - const iconPath = icons[type]; - - if (!iconPath) { - console.warn(`Icon type "${type}" not found`); - return null; - } - - const sizeClasses = { - sm: 'w-4 h-4', - md: 'w-5 h-5', - lg: 'w-6 h-6', - }; - - // size can be a keyword ('sm'|'md'|'lg') -> Tailwind class, or a number -> px. - const numeric = typeof size === 'number'; - const sizeClass = numeric ? '' : (sizeClasses[size] || sizeClasses.md); - const sizeStyle = numeric ? { width: size, height: size } : undefined; - - return ( - - {iconPath} - - ); -} - -Icon.propTypes = { - type: PropTypes.oneOf(Object.keys(icons)).isRequired, - size: PropTypes.oneOfType([PropTypes.oneOf(['sm', 'md', 'lg']), PropTypes.number]), - className: PropTypes.string, - style: PropTypes.object, -}; diff --git a/src/components/common/Icon.tsx b/src/components/common/Icon.tsx new file mode 100644 index 0000000..c1e9707 --- /dev/null +++ b/src/components/common/Icon.tsx @@ -0,0 +1,78 @@ +import type { CSSProperties, SVGProps } from 'react'; + +const PATHS = { + book: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253', + note: 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z', + folder: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z', + check: 'M5 13l4 4L19 7', + alert: + 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', + x: 'M6 18L18 6M6 6l12 12', + download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4', + upload: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12', + storage: + 'M5 19a2 2 0 01-2-2V7a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1M5 19h14a2 2 0 002-2v-5a2 2 0 00-2-2H9a2 2 0 00-2 2v5a2 2 0 01-2 2z', + device: 'M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z', + chart: + 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z', + info: 'M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + file: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z', + restore: + 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15', + bookmark: 'M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z', + history: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', + backup: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3v-8', + calendar: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z', + database: + 'M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4', + trash: + 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16', + search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z', + lock: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z', + shield: + 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', + globe: + 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z', + copy: 'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z', + external: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14', + menu: 'M4 6h16M4 12h16M4 18h16', + chevronDown: 'M19 9l-7 7-7-7', + arrowLeft: 'M10 19l-7-7m0 0l7-7m-7 7h18', +} as const; + +const ALIASES = { warning: 'alert', delete: 'trash', refresh: 'restore' } as const; + +export type IconType = keyof typeof PATHS | keyof typeof ALIASES; + +const SIZE_CLASSES = { sm: 'w-4 h-4', md: 'w-5 h-5', lg: 'w-6 h-6' }; + +export interface IconProps extends Omit, 'type'> { + type: IconType; + /** Keyword ('sm' | 'md' | 'lg') → Tailwind class, or a number → pixels. */ + size?: keyof typeof SIZE_CLASSES | number; + /** Accessible name; icons are decorative (aria-hidden) unless a title is given. */ + title?: string; + style?: CSSProperties; +} + +export function Icon({ type, size = 'md', className = '', style, title, ...props }: IconProps) { + const key = (type in ALIASES ? ALIASES[type as keyof typeof ALIASES] : type) as keyof typeof PATHS; + const numeric = typeof size === 'number'; + + return ( + + + + ); +} diff --git a/src/components/common/Modal.jsx b/src/components/common/Modal.jsx deleted file mode 100644 index 2dc0913..0000000 --- a/src/components/common/Modal.jsx +++ /dev/null @@ -1,102 +0,0 @@ -/** - * Modal component with clean professional design - */ - -import { useEffect } from 'react'; -import PropTypes from 'prop-types'; - -export function Modal({ - isOpen, - onClose, - children, - title = null, - size = 'md', - className = '', -}) { - // Close on ESC key - useEffect(() => { - const handleEscape = (e) => { - if (e.key === 'Escape' && isOpen) { - onClose(); - } - }; - - document.addEventListener('keydown', handleEscape); - return () => document.removeEventListener('keydown', handleEscape); - }, [isOpen, onClose]); - - // Prevent body scroll when modal is open - useEffect(() => { - if (isOpen) { - document.body.style.overflow = 'hidden'; - } else { - document.body.style.overflow = 'unset'; - } - - return () => { - document.body.style.overflow = 'unset'; - }; - }, [isOpen]); - - if (!isOpen) return null; - - const sizes = { - sm: 'max-w-md', - md: 'max-w-2xl', - lg: 'max-w-4xl', - xl: 'max-w-6xl', - }; - - const sizeClass = sizes[size] || sizes.md; - - return ( -
- {/* Backdrop */} -
- - {/* Modal content */} -
e.stopPropagation()} - > - {title && ( -
-

{title}

- -
- )} - {children} -
-
- ); -} - -Modal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - children: PropTypes.node.isRequired, - title: PropTypes.string, - size: PropTypes.oneOf(['sm', 'md', 'lg', 'xl']), - className: PropTypes.string, -}; diff --git a/src/components/common/Modal.tsx b/src/components/common/Modal.tsx new file mode 100644 index 0000000..7509ea6 --- /dev/null +++ b/src/components/common/Modal.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef, type ReactNode } from 'react'; + +export interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + title?: string; + /** Accessible label when no visible title is rendered. */ + ariaLabel?: string; + closeLabel?: string; + size?: 'sm' | 'md' | 'lg' | 'xl'; + className?: string; +} + +const SIZES = { sm: 'max-w-md', md: 'max-w-2xl', lg: 'max-w-4xl', xl: 'max-w-6xl' }; + +/** + * Accessible modal built on the native element: focus trapping, + * Escape-to-close and inert background come for free. + */ +export function Modal({ + isOpen, + onClose, + children, + title, + ariaLabel, + closeLabel = 'Close', + size = 'md', + className = '', +}: ModalProps) { + const dialogRef = useRef(null); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + if (isOpen && !dialog.open) dialog.showModal(); + if (!isOpen && dialog.open) dialog.close(); + }, [isOpen]); + + useEffect(() => { + const dialog = dialogRef.current; + if (!dialog) return; + // Clicking the ::backdrop targets the element itself. + const onBackdropClick = (e: MouseEvent) => { + if (e.target === dialog) onClose(); + }; + dialog.addEventListener('click', onBackdropClick); + return () => dialog.removeEventListener('click', onBackdropClick); + }, [onClose]); + + return ( + { + e.preventDefault(); + onClose(); + }} + aria-label={title ? undefined : ariaLabel} + aria-labelledby={title ? 'modal-title' : undefined} + className={`w-full ${SIZES[size]} m-auto max-h-[90vh] rounded-2xl bg-white p-0 shadow-elevated-hover backdrop:bg-kobo-overlay backdrop:backdrop-blur-xs open:animate-fade-in ${className}`} + > + {isOpen && ( +
+ {title && ( +
+ + +
+ )} + {children} +
+ )} +
+ ); +} diff --git a/src/components/common/ProgressBar.jsx b/src/components/common/ProgressBar.jsx deleted file mode 100644 index c806bf2..0000000 --- a/src/components/common/ProgressBar.jsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Linear progress bar with shimmer animation - */ - -import PropTypes from 'prop-types'; -import { formatProgress } from '../../utils/formatters.js'; - -export function ProgressBar({ - percent = 0, - showLabel = false, - label = null, - className = '', -}) { - const normalizedPercent = Math.min(100, Math.max(0, percent)); - - return ( -
- {(showLabel || label) && ( -
- - {label || 'Progress'} - - - {formatProgress(normalizedPercent)} - -
- )} -
-
-
-
- ); -} - -ProgressBar.propTypes = { - percent: PropTypes.number, - showLabel: PropTypes.bool, - label: PropTypes.string, - animated: PropTypes.bool, - className: PropTypes.string, -}; diff --git a/src/components/common/ProgressBar.tsx b/src/components/common/ProgressBar.tsx new file mode 100644 index 0000000..de98047 --- /dev/null +++ b/src/components/common/ProgressBar.tsx @@ -0,0 +1,36 @@ +import { formatProgress } from '../../utils/formatters.ts'; + +export interface ProgressBarProps { + percent?: number; + label?: string; + showLabel?: boolean; + className?: string; +} + +export function ProgressBar({ percent = 0, label, showLabel = false, className = '' }: ProgressBarProps) { + const value = Math.min(100, Math.max(0, percent)); + + return ( +
+ {(showLabel || label) && ( +
+ {label} + {formatProgress(value)} +
+ )} +
+
+
+
+ ); +} diff --git a/src/components/common/StatusBadge.jsx b/src/components/common/StatusBadge.jsx deleted file mode 100644 index d5159ec..0000000 --- a/src/components/common/StatusBadge.jsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Status badge component for success, warning, error states - */ - -import PropTypes from 'prop-types'; - -export function StatusBadge({ - children, - status = 'info', - icon = null, - className = '', -}) { - const variants = { - success: 'bg-kobo-success/10 text-kobo-success', - warning: 'bg-kobo-warning/10 text-kobo-warning', - error: 'bg-kobo-error/10 text-kobo-error', - info: 'bg-kobo-info/10 text-kobo-info', - default: 'bg-kobo-gray-light/20 text-kobo-gray', - }; - - const variantClass = variants[status] || variants.info; - - return ( - - {icon && {icon}} - {children} - - ); -} - -StatusBadge.propTypes = { - children: PropTypes.node.isRequired, - status: PropTypes.oneOf(['success', 'warning', 'error', 'info']), - icon: PropTypes.node, - className: PropTypes.string, -}; diff --git a/src/components/common/StatusBadge.tsx b/src/components/common/StatusBadge.tsx new file mode 100644 index 0000000..a603219 --- /dev/null +++ b/src/components/common/StatusBadge.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +export interface StatusBadgeProps { + children: ReactNode; + status?: 'success' | 'warning' | 'error' | 'info' | 'default'; + className?: string; +} + +const VARIANTS = { + success: 'bg-kobo-success/15 text-kobo-dark', + warning: 'bg-kobo-warning/15 text-kobo-dark', + error: 'bg-kobo-error/10 text-kobo-error', + info: 'bg-kobo-info/10 text-kobo-info', + default: 'bg-kobo-gray-light/20 text-kobo-gray', +}; + +export function StatusBadge({ children, status = 'info', className = '' }: StatusBadgeProps) { + return ( + + {children} + + ); +} diff --git a/src/components/layout/Container.jsx b/src/components/layout/Container.jsx deleted file mode 100644 index b4def0a..0000000 --- a/src/components/layout/Container.jsx +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Container component for consistent max-width and padding - */ - -import PropTypes from 'prop-types'; - -export function Container({ children, size = 'default', className = '' }) { - const sizes = { - sm: 'max-w-3xl', - default: 'max-w-7xl', - lg: 'max-w-screen-2xl', - full: 'max-w-full', - }; - - const sizeClass = sizes[size] || sizes.default; - - return ( -
- {children} -
- ); -} - -Container.propTypes = { - children: PropTypes.node.isRequired, - size: PropTypes.oneOf(['sm', 'default', 'lg', 'full']), - className: PropTypes.string, -}; diff --git a/src/components/layout/Container.tsx b/src/components/layout/Container.tsx new file mode 100644 index 0000000..3fce4d7 --- /dev/null +++ b/src/components/layout/Container.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from 'react'; + +export interface ContainerProps { + children: ReactNode; + size?: 'sm' | 'default' | 'lg' | 'full'; + className?: string; +} + +const SIZES = { + sm: 'max-w-3xl', + default: 'max-w-7xl', + lg: 'max-w-screen-2xl', + full: 'max-w-full', +}; + +export function Container({ children, size = 'default', className = '' }: ContainerProps) { + return
{children}
; +} diff --git a/src/components/layout/Footer.jsx b/src/components/layout/Footer.jsx index 11a05f1..7dcae87 100644 --- a/src/components/layout/Footer.jsx +++ b/src/components/layout/Footer.jsx @@ -2,8 +2,6 @@ * App footer with links and privacy info */ -import PropTypes from 'prop-types'; - export function Footer({ onNavigate }) { const currentYear = new Date().getFullYear(); @@ -15,8 +13,8 @@ export function Footer({ onNavigate }) {

Kobo Backup Manager

- A free, open-source tool to backup and restore your Kobo e-reader library. - All processing happens in your browser - your data never leaves your device. + A free, open-source tool to backup and restore your Kobo e-reader library. All processing + happens in your browser - your data never leaves your device.

@@ -57,7 +55,7 @@ export function Footer({ onNavigate }) { > GitHub - + @@ -69,19 +67,34 @@ export function Footer({ onNavigate }) {

Privacy First

- + No data sent to servers
- + No account required
- + Open source & auditable @@ -91,18 +104,10 @@ export function Footer({ onNavigate }) {
-

- © {currentYear} Kobo Backup Manager. Not affiliated with Rakuten Kobo Inc. -

-

- Made with ❤️ for book lovers everywhere -

+

© {currentYear} Kobo Backup Manager. Not affiliated with Rakuten Kobo Inc.

+

Made with ❤️ for book lovers everywhere

); } - -Footer.propTypes = { - onNavigate: PropTypes.func.isRequired, -}; diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.jsx index b00b92b..023ff4c 100644 --- a/src/components/layout/Header.jsx +++ b/src/components/layout/Header.jsx @@ -3,8 +3,7 @@ */ import { useState } from 'react'; -import { Button } from '../common/Button.jsx'; -import { useKoboStore } from '../../stores/koboStore.js'; +import { useKoboStore } from '../../stores/koboStore.ts'; export function Header({ currentPage = 'home', onNavigate }) { const [mobileMenuOpen, setMobileMenuOpen] = useState(false); @@ -19,7 +18,7 @@ export function Header({ currentPage = 'home', onNavigate }) { ]; return ( -
+
{/* Logo */} @@ -27,13 +26,8 @@ export function Header({ currentPage = 'home', onNavigate }) { onClick={() => onNavigate('home')} className="flex items-center gap-3 hover:opacity-80 transition-opacity" > -
- +
+
-

- Kobo Backup Manager -

-

- Never lose your library again -

+

Kobo Backup Manager

+

Never lose your library again

@@ -74,19 +64,9 @@ export function Header({ currentPage = 'home', onNavigate }) { onClick={() => setMobileMenuOpen(!mobileMenuOpen)} className="md:hidden p-2 rounded-lg hover:bg-kobo-cream-dark" > - + {mobileMenuOpen ? ( - + ) : ( -

- Backup Details -

+

Backup Details

Review your backup before restoring

{/* Backup Info */}
- - - - + + + +
{/* Reading Stats */}
-

- Reading Statistics -

+

Reading Statistics

-

- {preview.statistics.booksStarted} -

+

{preview.statistics.booksStarted}

Started

-

- {preview.statistics.booksFinished} -

+

{preview.statistics.booksFinished}

Finished

@@ -69,9 +49,7 @@ export function BackupPreview({ preview, compatibility, onRestore, onCancel }) { {/* Compatibility Check */} {compatibility && (
-

- Compatibility -

+

Compatibility

{compatibility.warnings.length === 0 ? ( @@ -86,7 +64,7 @@ export function BackupPreview({ preview, compatibility, onRestore, onCancel }) { className="p-3 bg-kobo-warning/10 border border-kobo-warning/20 rounded-lg text-sm font-body text-kobo-gray" >
- + {warning}
@@ -98,9 +76,7 @@ export function BackupPreview({ preview, compatibility, onRestore, onCancel }) { {/* Contents */}
-

- Backup Contents -

+

Backup Contents

Database @@ -127,18 +103,10 @@ export function BackupPreview({ preview, compatibility, onRestore, onCancel }) { {/* Action Buttons */}
- - diff --git a/src/components/restore/FileUploader.jsx b/src/components/restore/FileUploader.jsx index 797e2fe..1d8d885 100644 --- a/src/components/restore/FileUploader.jsx +++ b/src/components/restore/FileUploader.jsx @@ -3,9 +3,8 @@ */ import { useState, useCallback } from 'react'; -import { Card } from '../common/Card.jsx'; -import { Button } from '../common/Button.jsx'; -import { Icon } from '../common/Icon.jsx'; +import { Card } from '../common/Card.tsx'; +import { Icon } from '../common/Icon.tsx'; const LARGE_FILE_THRESHOLD = 2 * 1024 * 1024 * 1024; // 2 GB @@ -23,33 +22,37 @@ export function FileUploader({ onFileSelect, error }) { setIsDragging(false); }, []); - const handleDrop = useCallback((e) => { - e.preventDefault(); - setIsDragging(false); + const handleDrop = useCallback( + (e) => { + e.preventDefault(); + setIsDragging(false); - const files = Array.from(e.dataTransfer.files); - const zipFile = files.find(f => f.name.endsWith('.zip')); + const files = Array.from(e.dataTransfer.files); + const zipFile = files.find((f) => f.name.endsWith('.zip')); - if (zipFile) { - setLargeFileWarning(zipFile.size > LARGE_FILE_THRESHOLD); - onFileSelect(zipFile); - } - }, [onFileSelect]); + if (zipFile) { + setLargeFileWarning(zipFile.size > LARGE_FILE_THRESHOLD); + onFileSelect(zipFile); + } + }, + [onFileSelect], + ); - const handleFileInput = useCallback((e) => { - const file = e.target.files?.[0]; - if (file) { - setLargeFileWarning(file.size > LARGE_FILE_THRESHOLD); - onFileSelect(file); - } - }, [onFileSelect]); + const handleFileInput = useCallback( + (e) => { + const file = e.target.files?.[0]; + if (file) { + setLargeFileWarning(file.size > LARGE_FILE_THRESHOLD); + onFileSelect(file); + } + }, + [onFileSelect], + ); return (
-

- Select Backup File -

+

Select Backup File

Choose your Kobo backup ZIP file to restore

@@ -57,13 +60,12 @@ export function FileUploader({ onFileSelect, error }) { {/* Browser Compatibility Warning */}
- +
-

- Browser Compatibility -

+

Browser Compatibility

- Restore requires Chrome or Edge desktop browser. Not supported in Firefox, Safari, or mobile browsers. + Restore requires Chrome or Edge desktop browser. Not supported in Firefox, Safari, or mobile + browsers.

@@ -76,9 +78,10 @@ export function FileUploader({ onFileSelect, error }) { onDrop={handleDrop} className={` border-4 border-dashed rounded-2xl p-12 text-center transition-all - ${isDragging - ? 'border-kobo-accent bg-kobo-accent/5' - : 'border-kobo-gray-light bg-kobo-cream-dark/30' + ${ + isDragging + ? 'border-kobo-accent bg-kobo-accent/5' + : 'border-kobo-gray-light bg-kobo-cream-dark/30' } `} > @@ -92,17 +95,9 @@ export function FileUploader({ onFileSelect, error }) { {isDragging ? 'Drop your backup file here' : 'Drag & drop your backup file'} -

- or -

+

or

- +
{/* Large file warning */} {largeFileWarning && !error && (
- +

Large backup detected (>2 GB)

- Restoring large backups requires loading the entire ZIP into browser memory. - Make sure you have enough free RAM and no other heavy tabs open. - If it fails, close other applications and try again. + Restoring large backups requires loading the entire ZIP into browser memory. Make sure you + have enough free RAM and no other heavy tabs open. If it fails, close other applications and + try again.

@@ -140,14 +133,10 @@ export function FileUploader({ onFileSelect, error }) { {error && (
- +
-

- {error.title} -

-

- {error.message} -

+

{error.title}

+

{error.message}

diff --git a/src/components/restore/RestoreDeviceSelector.jsx b/src/components/restore/RestoreDeviceSelector.jsx index c991613..bff2e62 100644 --- a/src/components/restore/RestoreDeviceSelector.jsx +++ b/src/components/restore/RestoreDeviceSelector.jsx @@ -2,9 +2,9 @@ * Device selector for restore flow - distinct from backup flow */ -import { Card } from '../common/Card.jsx'; -import { Button } from '../common/Button.jsx'; -import { Icon } from '../common/Icon.jsx'; +import { Card } from '../common/Card.tsx'; +import { Button } from '../common/Button.tsx'; +import { Icon } from '../common/Icon.tsx'; export function RestoreDeviceSelector({ onSelectDevice, isSelecting, error }) { return ( @@ -15,9 +15,7 @@ export function RestoreDeviceSelector({ onSelectDevice, isSelecting, error }) {
-

- Select Restore Destination -

+

Select Restore Destination

Choose the Kobo device where you want to restore your backup

@@ -26,13 +24,14 @@ export function RestoreDeviceSelector({ onSelectDevice, isSelecting, error }) { {/* Important Warning */}
- +

Important: Data Will Be Replaced

- Restoring will replace the current database on your Kobo device. Make sure you've backed up your current library if needed. + Restoring will replace the current database on your Kobo device. Make sure you've backed up + your current library if needed.

@@ -40,23 +39,12 @@ export function RestoreDeviceSelector({ onSelectDevice, isSelecting, error }) { {/* Prerequisites Checklist */}
-

- Before You Continue -

+

Before You Continue

- - - - + + + +
@@ -64,14 +52,10 @@ export function RestoreDeviceSelector({ onSelectDevice, isSelecting, error }) { {error && (
- +
-

- {error.title} -

-

- {error.message} -

+

{error.title}

+

{error.message}

@@ -79,12 +63,7 @@ export function RestoreDeviceSelector({ onSelectDevice, isSelecting, error }) { {/* Action Buttons */}
- @@ -105,9 +84,9 @@ function ChecklistItem({ text, warning = false }) { return (
{text}
diff --git a/src/components/restore/RestoreOptions.jsx b/src/components/restore/RestoreOptions.jsx index 97ed09a..2a9dcea 100644 --- a/src/components/restore/RestoreOptions.jsx +++ b/src/components/restore/RestoreOptions.jsx @@ -2,10 +2,10 @@ * Restore options - choose what to restore and confirm */ -import { Card } from '../common/Card.jsx'; -import { Button } from '../common/Button.jsx'; -import { Checkbox } from '../common/Checkbox.jsx'; -import { Icon } from '../common/Icon.jsx'; +import { Card } from '../common/Card.tsx'; +import { Button } from '../common/Button.tsx'; +import { Checkbox } from '../common/Checkbox.tsx'; +import { Icon } from '../common/Icon.tsx'; export function RestoreOptions({ options, @@ -22,9 +22,7 @@ export function RestoreOptions({ return (
-

- Restore Options -

+

Restore Options

Choose what to restore to your Kobo

@@ -63,15 +61,14 @@ export function RestoreOptions({ {/* Warning */}
- +

Warning: This Will Erase Current Device Content

- Restoring this backup will replace all current content on your Kobo - device. Make sure you have a backup of any important data before - proceeding. + Restoring this backup will replace all current content on your Kobo device. Make sure you have + a backup of any important data before proceeding.

  • Current sideloaded books will be replaced
  • @@ -88,28 +85,17 @@ export function RestoreOptions({ checked={options.confirmed || false} onChange={handleConfirmChange} label={ - - I understand this will replace all content on the device - + I understand this will replace all content on the device } />
{/* Action Buttons */}
- - diff --git a/src/components/restore/RestoreProgress.jsx b/src/components/restore/RestoreProgress.jsx index da50238..cb619f6 100644 --- a/src/components/restore/RestoreProgress.jsx +++ b/src/components/restore/RestoreProgress.jsx @@ -2,9 +2,9 @@ * Restore progress screen */ -import { Card } from '../common/Card.jsx'; -import { CircularProgress } from '../common/CircularProgress.jsx'; -import { Icon } from '../common/Icon.jsx'; +import { Card } from '../common/Card.tsx'; +import { CircularProgress } from '../common/CircularProgress.tsx'; +import { Icon } from '../common/Icon.tsx'; export function RestoreProgress({ progress }) { const { stage, percent, filesProcessed, totalFiles } = progress; @@ -12,9 +12,7 @@ export function RestoreProgress({ progress }) { return (
-

- Restoring Your Library -

+

Restoring Your Library

{/* Circular Progress */}
@@ -22,9 +20,7 @@ export function RestoreProgress({ progress }) {
{/* Current Stage */} -

- {stage || 'Preparing...'} -

+

{stage || 'Preparing...'}

{/* Files Progress */} {filesProcessed !== undefined && totalFiles > 0 && ( @@ -40,30 +36,30 @@ export function RestoreProgress({ progress }) {
= 10 ? "completed" : percent > 0 ? "active" : "pending"} + status={percent >= 10 ? 'completed' : percent > 0 ? 'active' : 'pending'} /> = 20 ? "completed" : percent > 10 ? "active" : "pending"} + status={percent >= 20 ? 'completed' : percent > 10 ? 'active' : 'pending'} /> = 90 ? "completed" : percent > 20 ? "active" : "pending"} + status={percent >= 90 ? 'completed' : percent > 20 ? 'active' : 'pending'} /> = 95 ? "completed" : percent > 90 ? "active" : "pending"} + status={percent >= 95 ? 'completed' : percent > 90 ? 'active' : 'pending'} /> = 100 ? "completed" : percent > 95 ? "active" : "pending"} + status={percent >= 100 ? 'completed' : percent > 95 ? 'active' : 'pending'} />
{/* Critical Warning */}
- +

DO NOT disconnect your Kobo until complete!

@@ -81,7 +77,9 @@ function StageItem({ text, status }) { {status === 'active' && (
)} - {status === 'pending' &&
} + {status === 'pending' && ( +
+ )} {text}
); diff --git a/src/components/restore/RestoreSuccess.jsx b/src/components/restore/RestoreSuccess.jsx index b8d6b48..c8cceaf 100644 --- a/src/components/restore/RestoreSuccess.jsx +++ b/src/components/restore/RestoreSuccess.jsx @@ -2,9 +2,9 @@ * Restore success screen */ -import { Card } from '../common/Card.jsx'; -import { Button } from '../common/Button.jsx'; -import { Icon } from '../common/Icon.jsx'; +import { Card } from '../common/Card.tsx'; +import { Button } from '../common/Button.tsx'; +import { Icon } from '../common/Icon.tsx'; export function RestoreSuccess({ result, onDone }) { const failedBooks = result.failedBooks || []; @@ -16,7 +16,9 @@ export function RestoreSuccess({ result, onDone }) {
{/* Success / Partial-success Animation */} -
+
- + - +
{/* Failed Books Warning */} @@ -63,7 +57,7 @@ export function RestoreSuccess({ result, onDone }) {
    {failedBooks.map((b, i) => (
  • - • + • {b.name} {b.error && — {b.error}} @@ -82,9 +76,9 @@ export function RestoreSuccess({ result, onDone }) { Database count mismatch

    - The restored database contains {verification.dbBooksCount} books but the backup - reported {verification.expectedCount}. Some records may not have transferred. - Try ejecting the device and reconnecting to trigger a full rescan. + The restored database contains {verification.dbBooksCount} books but the backup reported{' '} + {verification.expectedCount}. Some records may not have transferred. Try ejecting the device and + reconnecting to trigger a full rescan.

)} @@ -97,7 +91,7 @@ export function RestoreSuccess({ result, onDone }) {
-
+
1
@@ -109,19 +103,17 @@ export function RestoreSuccess({ result, onDone }) {
-
+
2

Disconnect USB cable

-

- Unplug the USB cable from your Kobo device -

+

Unplug the USB cable from your Kobo device

-
+
3
@@ -135,7 +127,9 @@ export function RestoreSuccess({ result, onDone }) {
{/* Success / Partial message */} -
+

{hasFailures ? ( <> @@ -152,12 +146,7 @@ export function RestoreSuccess({ result, onDone }) {

{/* Done Button */} - diff --git a/src/components/restore/RestoreWizard.jsx b/src/components/restore/RestoreWizard.jsx index caa053f..616a82c 100644 --- a/src/components/restore/RestoreWizard.jsx +++ b/src/components/restore/RestoreWizard.jsx @@ -3,9 +3,9 @@ */ import { useState } from 'react'; -import { useFileSystem } from '../../hooks/useFileSystem.js'; -import { useRestore } from '../../hooks/useRestore.js'; -import { Container } from '../layout/Container.jsx'; +import { useFileSystem } from '../../hooks/useFileSystem.ts'; +import { useRestore } from '../../hooks/useRestore.ts'; +import { Container } from '../layout/Container.tsx'; import { RestoreDeviceSelector } from './RestoreDeviceSelector.jsx'; import { FileUploader } from './FileUploader.jsx'; @@ -105,12 +105,7 @@ export function RestoreWizard({ onComplete }) { return (
- {step === 'upload' && ( - - )} + {step === 'upload' && } {step === 'selectDevice' && ( )} - {step === 'progress' && ( - - )} + {step === 'progress' && } {step === 'success' && restore.result && ( - + )}
diff --git a/src/hooks/useBackup.js b/src/hooks/useBackup.js deleted file mode 100644 index f5ee1e6..0000000 --- a/src/hooks/useBackup.js +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Hook for creating backups (streaming-first approach). - * - * Flow: - * - Chrome/Edge: BackupWizard calls showSaveFilePicker() first (user-gesture), - * passes the fileHandle here -> streamBackupToDisk() -> one file at a time, no OOM. - * - Firefox/Safari: no fileHandle -> createBackupBlob() + saveBackup() -> may OOM - * for very large libraries, but that is unavoidable without native streaming support. - */ - -import { useState, useCallback } from 'react'; -import { - streamBackupToDisk, - createBackupBlob, - saveBackup, - estimateBackupSize, -} from '../utils/backup.js'; - -export function useBackup() { - const [isCreating, setIsCreating] = useState(false); - const [progress, setProgress] = useState({ - stage: '', percent: 0, filesProcessed: 0, totalFiles: 0, - }); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - /** - * Create a backup. - * - * @param {object} koboData - scan result; bookFiles items must have .handle - * @param {object} options - * @param {FileSystemFileHandle} [options.writableFileHandle] - from showSaveFilePicker (streaming path) - * @param {string} [options.suggestedFilename] - used as filename on the streaming path - */ - const create = useCallback(async (koboData, options = {}) => { - setIsCreating(true); - setError(null); - setResult(null); - - const startTime = Date.now(); - const totalFiles = koboData.bookFiles?.length || 0; - const { writableFileHandle, suggestedFilename, ...backupOptions } = options; - - const onProgress = (stage, percent, filesProcessed) => { - setProgress({ - stage, - percent: Math.min(Math.round(percent || 0), 100), - filesProcessed: filesProcessed ?? 0, - totalFiles, - }); - }; - - try { - let finalResult; - - if (writableFileHandle) { - // STREAMING PATH — no OOM regardless of library size - finalResult = await streamBackupToDisk( - koboData, - writableFileHandle, - suggestedFilename, - { ...backupOptions, onProgress }, - ); - } else { - // BLOB FALLBACK — Firefox / Safari (may OOM for large libraries) - const blobResult = await createBackupBlob(koboData, { ...backupOptions, onProgress }); - await saveBackup(blobResult.blob, blobResult.filename); - finalResult = { filename: blobResult.filename, size: blobResult.size, metadata: blobResult.metadata }; - } - - finalResult.duration = Date.now() - startTime; - setResult(finalResult); - setProgress({ stage: 'Backup complete', percent: 100, filesProcessed: totalFiles, totalFiles }); - return finalResult; - - } catch (err) { - setError({ - title: 'Backup Failed', - message: err.message || 'Failed to create backup', - code: err.code || 'BACKUP_FAILED', - }); - throw err; - } finally { - setIsCreating(false); - } - }, []); - - const estimateSize = useCallback((koboData) => estimateBackupSize(koboData), []); - - const reset = useCallback(() => { - setIsCreating(false); - setProgress({ stage: '', percent: 0, filesProcessed: 0, totalFiles: 0 }); - setResult(null); - setError(null); - }, []); - - return { - isCreating, - progress, - result, - error, - create, - estimateSize, - reset, - isComplete: result !== null, - }; -} diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts new file mode 100644 index 0000000..39be2ff --- /dev/null +++ b/src/hooks/useBackup.ts @@ -0,0 +1,101 @@ +/** + * Backup orchestration (streaming-first). + * + * - Chrome/Edge: BackupWizard calls showSaveFilePicker() first (user gesture) + * and passes the handle here -> streamBackupToDisk() -> no OOM. + * - Otherwise: createBackupBlob() + saveBackup() (may OOM on huge libraries). + */ + +import { useState, useCallback } from 'react'; +import { + streamBackupToDisk, + createBackupBlob, + saveBackup, + estimateBackupSize, + type BackupResult, +} from '../utils/backup.ts'; +import { errorCode, errorMessage } from '../utils/errors.ts'; +import type { BackupOptions, ProgressState, ScanResult, UiError } from '../types/kobo.ts'; + +export interface CreateBackupOptions extends Partial { + /** From showSaveFilePicker (streaming path). */ + writableFileHandle?: FileSystemFileHandle | null; + suggestedFilename?: string; +} + +export type BackupOutcome = BackupResult & { duration: number }; + +const IDLE: ProgressState = { stage: '', percent: 0, filesProcessed: 0, totalFiles: 0 }; + +export function useBackup() { + const [isCreating, setIsCreating] = useState(false); + const [progress, setProgress] = useState(IDLE); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const create = useCallback(async (koboData: ScanResult, options: CreateBackupOptions = {}) => { + setIsCreating(true); + setError(null); + setResult(null); + + const startTime = Date.now(); + const totalFiles = koboData.bookFiles?.length || 0; + const { writableFileHandle, suggestedFilename, ...backupOptions } = options; + + const onProgress = (stage: string, percent: number, filesProcessed?: number) => + setProgress({ + stage, + percent: Math.min(Math.round(percent || 0), 100), + filesProcessed: filesProcessed ?? 0, + totalFiles, + }); + + try { + let backupResult: BackupResult; + if (writableFileHandle) { + backupResult = await streamBackupToDisk( + koboData, + writableFileHandle, + suggestedFilename ?? 'backup.zip', + { + ...backupOptions, + onProgress, + }, + ); + } else { + const blobResult = await createBackupBlob(koboData, { ...backupOptions, onProgress }); + saveBackup(blobResult.blob, blobResult.filename); + backupResult = { + filename: blobResult.filename, + size: blobResult.size, + metadata: blobResult.metadata, + }; + } + + const finalResult = { ...backupResult, duration: Date.now() - startTime }; + setResult(finalResult); + setProgress({ stage: 'Backup complete', percent: 100, filesProcessed: totalFiles, totalFiles }); + return finalResult; + } catch (err) { + setError({ + title: 'Backup Failed', + message: errorMessage(err, 'Failed to create backup'), + code: errorCode(err, 'BACKUP_FAILED'), + }); + throw err; + } finally { + setIsCreating(false); + } + }, []); + + const estimateSize = useCallback((koboData: ScanResult) => estimateBackupSize(koboData), []); + + const reset = useCallback(() => { + setIsCreating(false); + setProgress(IDLE); + setResult(null); + setError(null); + }, []); + + return { isCreating, progress, result, error, create, estimateSize, reset, isComplete: result !== null }; +} diff --git a/src/hooks/useFeatureDetection.js b/src/hooks/useFeatureDetection.js deleted file mode 100644 index 92688c2..0000000 --- a/src/hooks/useFeatureDetection.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Hook to detect browser feature support - */ - -import { useState, useEffect } from 'react'; - -/** - * Detect browser features and capabilities - * @returns {object} Feature detection results - */ -export function useFeatureDetection() { - const [features, setFeatures] = useState({ - fileSystemAccess: false, - fileReader: false, - webAssembly: false, - serviceWorker: false, - blob: false, - loading: true, - }); - - useEffect(() => { - const detectFeatures = () => { - const detected = { - fileSystemAccess: 'showDirectoryPicker' in window, - fileReader: 'FileReader' in window, - webAssembly: typeof WebAssembly !== 'undefined', - serviceWorker: 'serviceWorker' in navigator, - blob: typeof Blob !== 'undefined', - loading: false, - }; - - setFeatures(detected); - }; - - detectFeatures(); - }, []); - - // Check if all critical features are supported - const isSupported = - features.fileReader && - features.webAssembly && - features.blob; - - // Check if modern features are supported - const hasModernFeatures = features.fileSystemAccess; - - return { - ...features, - isSupported, - hasModernFeatures, - }; -} diff --git a/src/hooks/useFeatureDetection.ts b/src/hooks/useFeatureDetection.ts new file mode 100644 index 0000000..b7de46b --- /dev/null +++ b/src/hooks/useFeatureDetection.ts @@ -0,0 +1,40 @@ +import { useSyncExternalStore } from 'react'; + +export interface BrowserFeatures { + fileSystemAccess: boolean; + saveFilePicker: boolean; + webAssembly: boolean; + isSupported: boolean; + hasModernFeatures: boolean; +} + +const SERVER_FEATURES: BrowserFeatures = { + fileSystemAccess: false, + saveFilePicker: false, + webAssembly: true, + isSupported: true, + hasModernFeatures: true, +}; + +let cached: BrowserFeatures | null = null; + +function detect(): BrowserFeatures { + if (cached) return cached; + const fileSystemAccess = 'showDirectoryPicker' in window; + const webAssembly = typeof WebAssembly !== 'undefined'; + cached = { + fileSystemAccess, + saveFilePicker: 'showSaveFilePicker' in window, + webAssembly, + isSupported: webAssembly && typeof Blob !== 'undefined', + hasModernFeatures: fileSystemAccess, + }; + return cached; +} + +const noopSubscribe = () => () => {}; + +/** Browser capability detection (stable, SSR-safe). */ +export function useFeatureDetection(): BrowserFeatures { + return useSyncExternalStore(noopSubscribe, detect, () => SERVER_FEATURES); +} diff --git a/src/hooks/useFileSystem.js b/src/hooks/useFileSystem.js deleted file mode 100644 index 32455d7..0000000 --- a/src/hooks/useFileSystem.js +++ /dev/null @@ -1,99 +0,0 @@ -/** - * Hook for File System Access API operations - */ - -import { useState, useCallback } from 'react'; -import { selectKoboDirectory, getAllFiles, getFileByPath } from '../utils/fileSystem.js'; -import { isValidKoboDirectory } from '../utils/validation.js'; - -/** - * Hook to manage file system access - * @returns {object} File system utilities and state - */ -export function useFileSystem() { - const [directory, setDirectory] = useState(null); - const [isSelecting, setIsSelecting] = useState(false); - const [error, setError] = useState(null); - - /** - * Select a directory using File System Access API - */ - const selectDirectory = useCallback(async () => { - setIsSelecting(true); - setError(null); - - try { - const dirHandle = await selectKoboDirectory(); - - if (dirHandle) { - // Validate it's a Kobo directory - const isValid = await isValidKoboDirectory(dirHandle); - - if (!isValid) { - setError({ - title: 'Not a Kobo Device', - message: 'Please select your Kobo drive (usually named "KOBOeReader")', - code: 'INVALID_DEVICE', - }); - setDirectory(null); - return null; - } - - setDirectory(dirHandle); - return dirHandle; - } - - return null; - } catch (err) { - setError({ - title: 'Selection Failed', - message: err.message || 'Failed to select directory', - code: err.code || 'UNKNOWN', - }); - return null; - } finally { - setIsSelecting(false); - } - }, []); - - /** - * Get all files from selected directory - */ - const getFiles = useCallback(async () => { - if (!directory) { - throw new Error('No directory selected'); - } - - return await getAllFiles(directory); - }, [directory]); - - /** - * Get a specific file by path - */ - const getFile = useCallback(async (path) => { - if (!directory) { - throw new Error('No directory selected'); - } - - return await getFileByPath(directory, path); - }, [directory]); - - /** - * Clear selected directory - */ - const clearDirectory = useCallback(() => { - setDirectory(null); - setError(null); - }, []); - - return { - directory, - isSelecting, - error, - selectDirectory, - getFiles, - getFile, - clearDirectory, - hasDirectory: directory !== null, - }; -} diff --git a/src/hooks/useFileSystem.ts b/src/hooks/useFileSystem.ts new file mode 100644 index 0000000..eb07dd2 --- /dev/null +++ b/src/hooks/useFileSystem.ts @@ -0,0 +1,50 @@ +import { useState, useCallback } from 'react'; +import { selectKoboDirectory } from '../utils/fileSystem.ts'; +import { isValidKoboDirectory } from '../utils/validation.ts'; +import { errorCode, errorMessage } from '../utils/errors.ts'; +import type { UiError } from '../types/kobo.ts'; + +/** Pick and validate the Kobo device folder. */ +export function useFileSystem() { + const [directory, setDirectory] = useState(null); + const [isSelecting, setIsSelecting] = useState(false); + const [error, setError] = useState(null); + + const selectDirectory = useCallback(async (): Promise => { + setIsSelecting(true); + setError(null); + try { + const dirHandle = await selectKoboDirectory(); + if (!dirHandle) return null; + + if (!(await isValidKoboDirectory(dirHandle))) { + setError({ + title: 'Not a Kobo Device', + message: 'Please select your Kobo drive (usually named "KOBOeReader")', + code: 'INVALID_DEVICE', + }); + setDirectory(null); + return null; + } + + setDirectory(dirHandle); + return dirHandle; + } catch (err) { + setError({ + title: 'Selection Failed', + message: errorMessage(err, 'Failed to select directory'), + code: errorCode(err, 'UNKNOWN'), + }); + return null; + } finally { + setIsSelecting(false); + } + }, []); + + const clearDirectory = useCallback(() => { + setDirectory(null); + setError(null); + }, []); + + return { directory, isSelecting, error, selectDirectory, clearDirectory, hasDirectory: directory !== null }; +} diff --git a/src/hooks/useKoboDevice.js b/src/hooks/useKoboDevice.js deleted file mode 100644 index 7c34023..0000000 --- a/src/hooks/useKoboDevice.js +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Hook for interacting with Kobo devices - */ - -import { useState, useCallback } from 'react'; -import { getFileByPath, readFile, getAllFiles } from '../utils/fileSystem.js'; -import { extractAllData } from '../utils/koboDatabase.js'; -import { isValidBookFile } from '../utils/validation.js'; - -/** - * Hook to detect and read Kobo device data - * @returns {object} Kobo device utilities and state - */ -export function useKoboDevice() { - const [device, setDevice] = useState(null); - const [books, setBooks] = useState([]); - const [annotations, setAnnotations] = useState([]); - const [stats, setStats] = useState(null); - const [bookFiles, setBookFiles] = useState([]); - - const [isScanning, setIsScanning] = useState(false); - const [scanProgress, setScanProgress] = useState({ - stage: '', - current: 0, - total: 0, - }); - const [error, setError] = useState(null); - - /** - * Scan Kobo device and extract all data - * @param {FileSystemDirectoryHandle} dirHandle - Kobo device directory - */ - const scanDevice = useCallback(async (dirHandle) => { - if (!dirHandle) { - throw new Error('No directory handle provided'); - } - - setIsScanning(true); - setError(null); - - try { - // Step 1: Read database - setScanProgress({ - stage: 'Reading database...', - current: 1, - total: 4, - }); - - const dbFileHandle = await getFileByPath(dirHandle, '.kobo/KoboReader.sqlite'); - const dbArrayBuffer = await readFile(dbFileHandle); - - // Step 2: Extract data from database - setScanProgress({ - stage: 'Analyzing books...', - current: 2, - total: 4, - }); - - const extractedData = await extractAllData(dbArrayBuffer); - - setBooks(extractedData.books); - setAnnotations(extractedData.annotations); - setStats(extractedData.stats); - setDevice(extractedData.deviceInfo); - - // Step 3: Find book files on device - setScanProgress({ - stage: 'Finding book files...', - current: 3, - total: 4, - }); - - const allFiles = await getAllFiles(dirHandle); - const filteredFiles = allFiles.filter(file => isValidBookFile(file.name)); - - // Fetch file size for each book (metadata only, no content read) - // so that estimateBackupSize() can return an accurate value. - const foundBookFiles = await Promise.all( - filteredFiles.map(async (file) => { - try { - const f = await file.handle.getFile(); - return { ...file, size: f.size }; - } catch { - return { ...file, size: 0 }; - } - }) - ); - - setBookFiles(foundBookFiles); - - // Step 4: Complete - setScanProgress({ - stage: 'Scan complete', - current: 4, - total: 4, - }); - - return { - books: extractedData.books, - annotations: extractedData.annotations, - stats: extractedData.stats, - deviceInfo: extractedData.deviceInfo, - bookFiles: foundBookFiles, - database: dbArrayBuffer, - }; - } catch (err) { - setError({ - title: 'Scan Failed', - message: err.message || 'Failed to scan Kobo device', - code: err.code || 'SCAN_FAILED', - }); - throw err; - } finally { - setIsScanning(false); - } - }, []); - - /** - * Get book by ContentID - */ - const getBookById = useCallback((contentId) => { - return books.find(book => book.ContentID === contentId); - }, [books]); - - /** - * Get annotations for a specific book - */ - const getAnnotationsForBook = useCallback((contentId) => { - return annotations.filter(annotation => annotation.VolumeID === contentId); - }, [annotations]); - - /** - * Clear all device data - */ - const clearDevice = useCallback(() => { - setDevice(null); - setBooks([]); - setAnnotations([]); - setStats(null); - setBookFiles([]); - setError(null); - setScanProgress({ stage: '', current: 0, total: 0 }); - }, []); - - return { - device, - books, - annotations, - stats, - bookFiles, - - isScanning, - scanProgress, - error, - - scanDevice, - getBookById, - getAnnotationsForBook, - clearDevice, - - hasDevice: device !== null, - bookCount: books.length, - annotationCount: annotations.length, - }; -} diff --git a/src/hooks/useKoboDevice.ts b/src/hooks/useKoboDevice.ts new file mode 100644 index 0000000..4667b74 --- /dev/null +++ b/src/hooks/useKoboDevice.ts @@ -0,0 +1,74 @@ +import { useState, useCallback } from 'react'; +import { getFileByPath, readFile, getAllFiles } from '../utils/fileSystem.ts'; +import { extractAllData } from '../utils/koboDatabase.ts'; +import { isValidBookFile } from '../utils/validation.ts'; +import { errorCode, errorMessage } from '../utils/errors.ts'; +import type { BookFileEntry, ScanResult, UiError } from '../types/kobo.ts'; + +export interface ScanProgress { + stage: string; + current: number; + total: number; +} + +const IDLE: ScanProgress = { stage: '', current: 0, total: 0 }; + +/** Scan a Kobo device: database + book files. */ +export function useKoboDevice() { + const [isScanning, setIsScanning] = useState(false); + const [scanProgress, setScanProgress] = useState(IDLE); + const [error, setError] = useState(null); + + const scanDevice = useCallback(async (dirHandle: FileSystemDirectoryHandle): Promise => { + setIsScanning(true); + setError(null); + const step = (current: number, stage: string) => setScanProgress({ stage, current, total: 4 }); + + try { + step(1, 'Reading database...'); + const database = await readFile(await getFileByPath(dirHandle, '.kobo/KoboReader.sqlite')); + + step(2, 'Analyzing books...'); + const extracted = await extractAllData(database); + + step(3, 'Finding book files...'); + const candidates = (await getAllFiles(dirHandle)).filter((file) => isValidBookFile(file.name)); + // File size only (metadata, no content read) so the size estimate is accurate. + const bookFiles: BookFileEntry[] = await Promise.all( + candidates.map(async (file) => { + try { + return { ...file, size: (await file.handle.getFile()).size }; + } catch { + return { ...file, size: 0 }; + } + }), + ); + + step(4, 'Scan complete'); + return { + books: extracted.books, + annotations: extracted.annotations, + stats: extracted.stats, + deviceInfo: extracted.deviceInfo, + bookFiles, + database, + }; + } catch (err) { + setError({ + title: 'Scan Failed', + message: errorMessage(err, 'Failed to scan Kobo device'), + code: errorCode(err, 'SCAN_FAILED'), + }); + throw err; + } finally { + setIsScanning(false); + } + }, []); + + const clearDevice = useCallback(() => { + setError(null); + setScanProgress(IDLE); + }, []); + + return { isScanning, scanProgress, error, scanDevice, clearDevice }; +} diff --git a/src/hooks/useRestore.js b/src/hooks/useRestore.js deleted file mode 100644 index d19d0ba..0000000 --- a/src/hooks/useRestore.js +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Hook for restoring backups - */ - -import { useState, useCallback } from 'react'; -import { - parseBackupFile, - restoreToDevice, - checkCompatibility, - previewBackup, - validateRestoreTarget, -} from '../utils/restore.js'; - -/** - * Hook to manage backup restoration - * @returns {object} Restore utilities and state - */ -export function useRestore() { - const [isParsing, setIsParsing] = useState(false); - const [isRestoring, setIsRestoring] = useState(false); - const [backupData, setBackupData] = useState(null); - const [preview, setPreview] = useState(null); - const [compatibility, setCompatibility] = useState(null); - - const [progress, setProgress] = useState({ - stage: '', - percent: 0, - filesProcessed: 0, - totalFiles: 0, - }); - - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - /** - * Parse a backup file - * @param {File} file - Backup ZIP file - */ - const parse = useCallback(async (file) => { - setIsParsing(true); - setError(null); - - try { - const parsed = await parseBackupFile(file); - setBackupData(parsed); - - // Generate preview - const previewData = previewBackup(parsed); - setPreview(previewData); - - return parsed; - } catch (err) { - setError({ - title: 'Invalid Backup File', - message: err.message || 'Failed to parse backup file', - code: err.code || 'PARSE_FAILED', - }); - throw err; - } finally { - setIsParsing(false); - } - }, []); - - /** - * Check compatibility with target device - * @param {object} deviceInfo - Target device information - */ - const checkDeviceCompatibility = useCallback((deviceInfo) => { - if (!backupData) { - throw new Error('No backup loaded'); - } - - const compat = checkCompatibility(backupData.metadata, deviceInfo); - setCompatibility(compat); - - return compat; - }, [backupData]); - - /** - * Validate restore target - * @param {FileSystemDirectoryHandle} deviceHandle - Target device directory - */ - const validateTarget = useCallback(async (deviceHandle) => { - return await validateRestoreTarget(deviceHandle); - }, []); - - /** - * Restore backup to device - * @param {FileSystemDirectoryHandle} deviceHandle - Target device directory - * @param {object} options - Restore options - */ - const restore = useCallback(async (deviceHandle, options = {}) => { - if (!backupData) { - throw new Error('No backup loaded'); - } - - setIsRestoring(true); - setError(null); - setResult(null); - - const startTime = Date.now(); - - try { - // Validate target first - const validation = await validateTarget(deviceHandle); - if (!validation.valid) { - throw new Error(validation.error); - } - - // Restore with progress tracking - const restoreResult = await restoreToDevice(deviceHandle, backupData, { - ...options, - onProgress: (progressData) => { - setProgress({ - stage: progressData.stage, - percent: progressData.percent, - filesProcessed: progressData.filesProcessed || 0, - totalFiles: progressData.totalFiles || backupData.bookFiles.length, - }); - }, - }); - - const finalResult = { - ...restoreResult, - duration: Date.now() - startTime, - failedBooks: restoreResult.failedBooks || [], - verification: restoreResult.verification || null, - }; - - setResult(finalResult); - setProgress({ - stage: 'Restore complete', - percent: 100, - filesProcessed: backupData.bookFiles.length, - totalFiles: backupData.bookFiles.length, - }); - - return finalResult; - } catch (err) { - setError({ - title: 'Restore Failed', - message: err.message || 'Failed to restore backup', - code: err.code || 'RESTORE_FAILED', - }); - throw err; - } finally { - setIsRestoring(false); - } - }, [backupData, validateTarget]); - - /** - * Clear backup data and reset state - */ - const clear = useCallback(() => { - setBackupData(null); - setPreview(null); - setCompatibility(null); - setProgress({ - stage: '', - percent: 0, - filesProcessed: 0, - totalFiles: 0, - }); - setResult(null); - setError(null); - }, []); - - return { - isParsing, - isRestoring, - backupData, - preview, - compatibility, - progress, - result, - error, - - parse, - checkDeviceCompatibility, - validateTarget, - restore, - clear, - - hasBackup: backupData !== null, - isComplete: result !== null, - }; -} diff --git a/src/hooks/useRestore.ts b/src/hooks/useRestore.ts new file mode 100644 index 0000000..0772fdd --- /dev/null +++ b/src/hooks/useRestore.ts @@ -0,0 +1,135 @@ +import { useState, useCallback } from 'react'; +import { + parseBackupFile, + restoreToDevice, + checkCompatibility, + previewBackup, + validateRestoreTarget, + type BackupPreviewData, + type CompatibilityResult, + type ParsedBackup, + type RestoreResult, + type RestoreRunOptions, +} from '../utils/restore.ts'; +import { errorCode, errorMessage } from '../utils/errors.ts'; +import type { DeviceInfo, ProgressState, UiError } from '../types/kobo.ts'; + +export type RestoreOutcome = RestoreResult & { duration: number }; + +const IDLE: ProgressState = { stage: '', percent: 0, filesProcessed: 0, totalFiles: 0 }; + +/** Restore orchestration: parse → preview → restore. */ +export function useRestore() { + const [isParsing, setIsParsing] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const [backupData, setBackupData] = useState(null); + const [preview, setPreview] = useState(null); + const [compatibility, setCompatibility] = useState(null); + const [progress, setProgress] = useState(IDLE); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + + const parse = useCallback(async (file: File) => { + setIsParsing(true); + setError(null); + try { + const parsed = await parseBackupFile(file); + setBackupData(parsed); + setPreview(previewBackup(parsed)); + return parsed; + } catch (err) { + setError({ + title: 'Invalid Backup File', + message: errorMessage(err, 'Failed to parse backup file'), + code: errorCode(err, 'PARSE_FAILED'), + }); + throw err; + } finally { + setIsParsing(false); + } + }, []); + + const checkDeviceCompatibility = useCallback( + (deviceInfo: Partial | null) => { + if (!backupData) throw new Error('No backup loaded'); + const compat = checkCompatibility(backupData.metadata, deviceInfo); + setCompatibility(compat); + return compat; + }, + [backupData], + ); + + const restore = useCallback( + async (deviceHandle: FileSystemDirectoryHandle, options: RestoreRunOptions = {}) => { + if (!backupData) throw new Error('No backup loaded'); + + setIsRestoring(true); + setError(null); + setResult(null); + const startTime = Date.now(); + const totalBooks = backupData.bookFiles.length; + + try { + const validation = await validateRestoreTarget(deviceHandle); + if (!validation.valid) throw new Error(validation.error); + + const restoreResult = await restoreToDevice(deviceHandle, backupData, { + ...options, + onProgress: (p) => + setProgress({ + stage: p.stage, + percent: p.percent, + filesProcessed: p.filesProcessed || 0, + totalFiles: p.totalFiles || totalBooks, + }), + }); + + const finalResult = { ...restoreResult, duration: Date.now() - startTime }; + setResult(finalResult); + setProgress({ + stage: 'Restore complete', + percent: 100, + filesProcessed: totalBooks, + totalFiles: totalBooks, + }); + return finalResult; + } catch (err) { + setError({ + title: 'Restore Failed', + message: errorMessage(err, 'Failed to restore backup'), + code: errorCode(err, 'RESTORE_FAILED'), + }); + throw err; + } finally { + setIsRestoring(false); + } + }, + [backupData], + ); + + const clear = useCallback(() => { + setBackupData(null); + setPreview(null); + setCompatibility(null); + setProgress(IDLE); + setResult(null); + setError(null); + }, []); + + return { + isParsing, + isRestoring, + backupData, + preview, + compatibility, + progress, + result, + error, + parse, + checkDeviceCompatibility, + restore, + clear, + hasBackup: backupData !== null, + isComplete: result !== null, + }; +} diff --git a/src/main.jsx b/src/main.jsx index 73872c2..3341071 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,9 +1,8 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App.jsx' -import { ErrorBoundary } from './components/ErrorBoundary.jsx' -import './styles/globals.css' -import './styles/animations.css' +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import { ErrorBoundary } from './components/ErrorBoundary.jsx'; +import './styles/globals.css'; ReactDOM.createRoot(document.getElementById('root')).render( @@ -11,4 +10,4 @@ ReactDOM.createRoot(document.getElementById('root')).render( , -) +); diff --git a/src/pages/FAQ.jsx b/src/pages/FAQ.jsx index 1dba05b..eba8bf9 100644 --- a/src/pages/FAQ.jsx +++ b/src/pages/FAQ.jsx @@ -3,8 +3,7 @@ */ import { useState, useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { Button } from '../components/common/Button.jsx'; +import { Button } from '../components/common/Button.tsx'; const faqs = [ { @@ -12,110 +11,131 @@ const faqs = [ questions: [ { question: 'What is Kobo Backup Manager?', - answer: 'Kobo Backup Manager is a free, open-source web application that allows you to backup and restore your Kobo e-reader library entirely within your browser. It processes all data locally without sending anything to external servers, ensuring complete privacy.' + answer: + 'Kobo Backup Manager is a free, open-source web application that allows you to backup and restore your Kobo e-reader library entirely within your browser. It processes all data locally without sending anything to external servers, ensuring complete privacy.', }, { question: 'Which browsers are supported?', - answer: 'Kobo Backup Manager requires Chrome 86 or later, Microsoft Edge 86 or later, or any Chromium-based browser that supports the File System Access API. Unfortunately, Safari and Firefox do not currently support the required APIs.' + answer: + 'Kobo Backup Manager requires Chrome 86 or later, Microsoft Edge 86 or later, or any Chromium-based browser that supports the File System Access API. Unfortunately, Safari and Firefox do not currently support the required APIs.', }, { question: 'Do I need to create an account?', - answer: 'No account is required. Kobo Backup Manager works entirely in your browser without any registration, login, or personal information collection.' + answer: + 'No account is required. Kobo Backup Manager works entirely in your browser without any registration, login, or personal information collection.', }, { question: 'Is Kobo Backup Manager free?', - answer: 'Yes, Kobo Backup Manager is completely free and open source. The source code is available on GitHub for anyone to review, use, or contribute to.' - } - ] + answer: + 'Yes, Kobo Backup Manager is completely free and open source. The source code is available on GitHub for anyone to review, use, or contribute to.', + }, + ], }, { category: 'Backup & Restore', questions: [ { question: 'What data does a backup include?', - answer: 'A backup includes your Kobo library database (books, reading progress, highlights, annotations, bookmarks, collections, shelves) and optionally the book files themselves. You can choose which data to include when creating a backup.' + answer: + 'A backup includes your Kobo library database (books, reading progress, highlights, annotations, bookmarks, collections, shelves) and optionally the book files themselves. You can choose which data to include when creating a backup.', }, { question: 'How long does it take to create a backup?', - answer: 'Backup time depends on your library size. Small libraries (under 100 books) typically take 30-60 seconds, while larger libraries (500+ books) may take 3-5 minutes. All processing happens in your browser, so performance varies based on your device.' + answer: + 'Backup time depends on your library size. Small libraries (under 100 books) typically take 30-60 seconds, while larger libraries (500+ books) may take 3-5 minutes. All processing happens in your browser, so performance varies based on your device.', }, { question: 'Where are my backups stored?', - answer: 'Backups are saved as ZIP files to a location of your choice on your computer. You have complete control over where backups are stored, and they can be moved or copied like any other file.' + answer: + 'Backups are saved as ZIP files to a location of your choice on your computer. You have complete control over where backups are stored, and they can be moved or copied like any other file.', }, { question: 'Can I restore a backup to a different Kobo device?', - answer: 'Yes, you can restore backups to any Kobo e-reader. This is useful when upgrading to a new device or recovering from a device reset. Note that some device-specific settings may not transfer.' + answer: + 'Yes, you can restore backups to any Kobo e-reader. This is useful when upgrading to a new device or recovering from a device reset. Note that some device-specific settings may not transfer.', }, { question: 'Will restoring delete my current library?', - answer: 'Restoring a backup will replace the database on your Kobo device, which effectively replaces your current library with the backed-up version. Always create a backup of your current library before restoring an older one.' - } - ] + answer: + 'Restoring a backup will replace the database on your Kobo device, which effectively replaces your current library with the backed-up version. Always create a backup of your current library before restoring an older one.', + }, + ], }, { category: 'Privacy & Security', questions: [ { question: 'Is my data sent to any servers?', - answer: 'No. All processing happens entirely within your browser using JavaScript and WebAssembly. No data is uploaded to any server or cloud service. Your library data never leaves your device.' + answer: + 'No. All processing happens entirely within your browser using JavaScript and WebAssembly. No data is uploaded to any server or cloud service. Your library data never leaves your device.', }, { question: 'Does Kobo Backup Manager use analytics?', - answer: 'Yes, we use Google Analytics 4 (GA4) and ContentSquare to understand how users interact with the app and improve the service. These tools collect anonymous usage statistics but do not access your Kobo library data or backup files. See our Privacy Policy for full details.' + answer: + 'Yes, we use Google Analytics 4 (GA4) and ContentSquare to understand how users interact with the app and improve the service. These tools collect anonymous usage statistics but do not access your Kobo library data or backup files. See our Privacy Policy for full details.', }, { question: 'Can I verify the code is safe?', - answer: 'Yes! Kobo Backup Manager is fully open source. You can review the complete source code on GitHub to verify what it does and ensure it meets your security standards.' + answer: + 'Yes! Kobo Backup Manager is fully open source. You can review the complete source code on GitHub to verify what it does and ensure it meets your security standards.', }, { question: 'Is this affiliated with Rakuten Kobo?', - answer: 'No, Kobo Backup Manager is an independent, community-created tool and is not affiliated with, endorsed by, or supported by Rakuten Kobo Inc.' - } - ] + answer: + 'No, Kobo Backup Manager is an independent, community-created tool and is not affiliated with, endorsed by, or supported by Rakuten Kobo Inc.', + }, + ], }, { category: 'Technical Questions', questions: [ { - question: 'Why can\'t I use Firefox or Safari?', - answer: 'Kobo Backup Manager requires the File System Access API to directly read from and write to your Kobo device. This API is currently only available in Chromium-based browsers (Chrome, Edge, Opera, Brave). Firefox and Safari have not yet implemented this standard.' + question: "Why can't I use Firefox or Safari?", + answer: + 'Kobo Backup Manager requires the File System Access API to directly read from and write to your Kobo device. This API is currently only available in Chromium-based browsers (Chrome, Edge, Opera, Brave). Firefox and Safari have not yet implemented this standard.', }, { question: 'What is the File System Access API?', - answer: 'The File System Access API is a modern web standard that allows web applications to read and write files with user permission. It enables Kobo Backup Manager to access your Kobo device without requiring browser extensions or native applications.' + answer: + 'The File System Access API is a modern web standard that allows web applications to read and write files with user permission. It enables Kobo Backup Manager to access your Kobo device without requiring browser extensions or native applications.', }, { question: 'Does this work offline?', - answer: 'After the initial load, Kobo Backup Manager can work offline. The app is cached in your browser, so you can create and restore backups without an internet connection once you\'ve visited the site.' + answer: + "After the initial load, Kobo Backup Manager can work offline. The app is cached in your browser, so you can create and restore backups without an internet connection once you've visited the site.", }, { question: 'Can I use this on mobile devices?', - answer: 'Currently, mobile browsers do not support the File System Access API needed to read from Kobo devices. Kobo Backup Manager is designed for desktop use with Chrome, Edge, or other Chromium-based browsers.' - } - ] + answer: + 'Currently, mobile browsers do not support the File System Access API needed to read from Kobo devices. Kobo Backup Manager is designed for desktop use with Chrome, Edge, or other Chromium-based browsers.', + }, + ], }, { category: 'Troubleshooting', questions: [ { - question: 'I can\'t find the KoboReader.sqlite file', - answer: 'The KoboReader.sqlite file is located in the .kobo folder inside your Kobo device. Make sure your Kobo is connected via USB and recognized as a storage device. On Windows, look for the .kobo folder in your Kobo\'s root directory. Note that folders starting with a dot may be hidden by default.' + question: "I can't find the KoboReader.sqlite file", + answer: + "The KoboReader.sqlite file is located in the .kobo folder inside your Kobo device. Make sure your Kobo is connected via USB and recognized as a storage device. On Windows, look for the .kobo folder in your Kobo's root directory. Note that folders starting with a dot may be hidden by default.", }, { question: 'The backup process failed or got stuck', - answer: 'Try disconnecting and reconnecting your Kobo device, then close and reopen your browser. If the issue persists, your Kobo database may be corrupted - try connecting your Kobo to Kobo Desktop software first to let it repair the database.' + answer: + 'Try disconnecting and reconnecting your Kobo device, then close and reopen your browser. If the issue persists, your Kobo database may be corrupted - try connecting your Kobo to Kobo Desktop software first to let it repair the database.', }, { - question: 'My device isn\'t recognized', - answer: 'Ensure your Kobo is connected via USB and is in USB storage mode (not charging only). Some USB cables are charge-only and won\'t work - use a data-capable USB cable. Also verify your browser has permission to access files.' + question: "My device isn't recognized", + answer: + "Ensure your Kobo is connected via USB and is in USB storage mode (not charging only). Some USB cables are charge-only and won't work - use a data-capable USB cable. Also verify your browser has permission to access files.", }, { question: 'The app shows "Browser not supported"', - answer: 'This means you\'re using a browser that doesn\'t support the File System Access API. Please switch to Chrome 86+, Edge 86+, or another Chromium-based browser. Update your browser to the latest version if the message persists.' - } - ] - } + answer: + "This means you're using a browser that doesn't support the File System Access API. Please switch to Chrome 86+, Edge 86+, or another Chromium-based browser. Update your browser to the latest version if the message persists.", + }, + ], + }, ]; export function FAQ({ onNavigate }) { @@ -128,16 +148,16 @@ export function FAQ({ onNavigate }) { script.text = JSON.stringify({ '@context': 'https://schema.org', '@type': 'FAQPage', - mainEntity: faqs.flatMap(category => - category.questions.map(q => ({ + mainEntity: faqs.flatMap((category) => + category.questions.map((q) => ({ '@type': 'Question', name: q.question, acceptedAnswer: { '@type': 'Answer', - text: q.answer - } - })) - ) + text: q.answer, + }, + })), + ), }); document.head.appendChild(script); @@ -153,18 +173,12 @@ export function FAQ({ onNavigate }) { return (
-
-

- Frequently Asked Questions -

+

Frequently Asked Questions

Everything you need to know about backing up and restoring your Kobo e-reader library.

@@ -181,37 +195,28 @@ export function FAQ({ onNavigate }) { return (
{isOpen && (
-

- {faq.answer} -

+

{faq.answer}

)}
@@ -222,21 +227,14 @@ export function FAQ({ onNavigate }) { ))}
-

- Still have questions? -

+

Still have questions?

- Check out our comprehensive User Guide for detailed instructions, or visit our GitHub repository - to report issues and contribute. + Check out our comprehensive User Guide for detailed instructions, or visit our GitHub repository to + report issues and contribute.

- - +
@@ -244,7 +242,3 @@ export function FAQ({ onNavigate }) {
); } - -FAQ.propTypes = { - onNavigate: PropTypes.func.isRequired, -}; diff --git a/src/pages/History.jsx b/src/pages/History.jsx index 1a90f3d..0284360 100644 --- a/src/pages/History.jsx +++ b/src/pages/History.jsx @@ -3,13 +3,13 @@ */ import { useState } from 'react'; -import { useKoboStore } from '../stores/koboStore.js'; -import { Container } from '../components/layout/Container.jsx'; -import { Card } from '../components/common/Card.jsx'; -import { Button } from '../components/common/Button.jsx'; -import { Icon } from '../components/common/Icon.jsx'; -import { Modal } from '../components/common/Modal.jsx'; -import { formatBytes, formatDate } from '../utils/formatters.js'; +import { useKoboStore } from '../stores/koboStore.ts'; +import { Container } from '../components/layout/Container.tsx'; +import { Card } from '../components/common/Card.tsx'; +import { Button } from '../components/common/Button.tsx'; +import { Icon } from '../components/common/Icon.tsx'; +import { Modal } from '../components/common/Modal.tsx'; +import { formatBytes, formatDate } from '../utils/formatters.ts'; export function History({ onNavigate }) { const backups = useKoboStore((state) => state.backups); @@ -27,7 +27,7 @@ export function History({ onNavigate }) { setDeleteModal({ isOpen: false, backup: null }); }; - const handleRestore = (backup) => { + const handleRestore = () => { // Navigate to restore page // In a real implementation, you might want to pre-load the backup file if (onNavigate) { @@ -45,19 +45,13 @@ export function History({ onNavigate }) {
-

- No Backup History -

+

No Backup History

You haven't created any backups yet. Create your first backup to keep your Kobo library safe.

- @@ -73,34 +67,23 @@ export function History({ onNavigate }) {
-

- Backup History -

-

- View and manage your Kobo library backups -

+

Backup History

+

View and manage your Kobo library backups

{/* Backup List */}
{backups.map((backup) => ( - + ))}
{/* Info Section */}
- +
-

- Backup Best Practices -

+

Backup Best Practices

  • Create regular backups, especially before device updates
  • Store backups in multiple locations (cloud + external drive)
  • @@ -114,18 +97,13 @@ export function History({ onNavigate }) { {/* Delete Confirmation Modal */} - setDeleteModal({ isOpen: false, backup: null })} - > + setDeleteModal({ isOpen: false, backup: null })}>
    -

    - Delete Backup? -

    +

    Delete Backup?

    Are you sure you want to remove this backup from your history? This action cannot be undone. @@ -134,17 +112,12 @@ export function History({ onNavigate }) { {deleteModal.backup && (

    Backup File

    -

    - {deleteModal.backup.filename} -

    +

    {deleteModal.backup.filename}

    )}
    - -
    diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index 7db554e..a84efc7 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -2,12 +2,12 @@ * Home page - Landing with value proposition */ -import { Button } from '../components/common/Button.jsx'; -import { Card } from '../components/common/Card.jsx'; -import { Container } from '../components/layout/Container.jsx'; -import { Icon } from '../components/common/Icon.jsx'; +import { Button } from '../components/common/Button.tsx'; +import { Card } from '../components/common/Card.tsx'; +import { Container } from '../components/layout/Container.tsx'; +import { Icon } from '../components/common/Icon.tsx'; -import { useKoboStore } from '../stores/koboStore.js'; +import { useKoboStore } from '../stores/koboStore.ts'; export function Home({ onNavigate }) { const books = useKoboStore((state) => state.books); @@ -21,11 +21,12 @@ export function Home({ onNavigate }) {
    - 📲 Connected to {device?.model || 'Kobo Device'} with {books.length} books. + 📲 Connected to {device?.model || 'Kobo Device'}{' '} + with {books.length} books. @@ -35,7 +36,7 @@ export function Home({ onNavigate }) { )} {/* Hero Section */} -
    +

    @@ -45,8 +46,8 @@ export function Home({ onNavigate }) {

    - Free, secure backup for all your ebooks, annotations, and reading - progress. Works 100% in your browser. + Free, secure backup for all your ebooks, annotations, and reading progress. Works 100% in your + browser.

    @@ -101,12 +102,10 @@ export function Home({ onNavigate }) {
    -

    - Complete Protection -

    +

    Complete Protection

    - Backup all your sideloaded books, annotations, highlights, and - reading progress in one secure archive. + Backup all your sideloaded books, annotations, highlights, and reading progress in one secure + archive.

    @@ -114,12 +113,10 @@ export function Home({ onNavigate }) {
    -

    - Privacy First -

    +

    Privacy First

    - All processing happens in your browser. Your data never leaves your - device - no servers, no tracking, no accounts. + All processing happens in your browser. Your data never leaves your device - no servers, no + tracking, no accounts.

    @@ -127,12 +124,10 @@ export function Home({ onNavigate }) {
    -

    - Easy Restore -

    +

    Easy Restore

    - Moving to a new Kobo? Restore your entire library with just a few - clicks. Pick up right where you left off. + Moving to a new Kobo? Restore your entire library with just a few clicks. Pick up right where + you left off.

    @@ -148,57 +143,45 @@ export function Home({ onNavigate }) {
    -
    +
    1
    -

    - Connect Your Kobo -

    +

    Connect Your Kobo

    - Plug your Kobo into your computer via USB and unlock it. The device - will appear as a drive. + Plug your Kobo into your computer via USB and unlock it. The device will appear as a drive.

    -
    +
    2
    -

    - Select Your Device -

    +

    Select Your Device

    - Click the button to select your Kobo drive. The app will - automatically find your books and reading data. + Click the button to select your Kobo drive. The app will automatically find your books and + reading data.

    -
    +
    3
    -

    - Create Backup -

    +

    Create Backup

    - Everything is packaged into a single ZIP file and saved to your - Downloads folder. That's it! + Everything is packaged into a single ZIP file and saved to your Downloads folder. That's it!

    -
    @@ -214,13 +197,10 @@ export function Home({ onNavigate }) {
    -

    - Is my data safe? -

    +

    Is my data safe?

    - Absolutely! All processing happens locally in your browser. Your - books and data never leave your computer. We don't have any servers - to send data to. + Absolutely! All processing happens locally in your browser. Your books and data never leave + your computer. We don't have any servers to send data to.

    @@ -229,18 +209,16 @@ export function Home({ onNavigate }) { Does this work with Kobo store books?

    - This tool is designed for sideloaded books (books you added yourself). - Kobo store books are already backed up in your Kobo account. + This tool is designed for sideloaded books (books you added yourself). Kobo store books are + already backed up in your Kobo account.

    -

    - Which browsers are supported? -

    +

    Which browsers are supported?

    - For the best experience, use Chrome 86+, Edge 86+, or Opera 72+. Other - browsers may work with limited functionality. + For the best experience, use Chrome 86+, Edge 86+, or Opera 72+. Other browsers may work with + limited functionality.

    diff --git a/src/pages/LibraryDashboard.jsx b/src/pages/LibraryDashboard.jsx index 3d04128..6b209a6 100644 --- a/src/pages/LibraryDashboard.jsx +++ b/src/pages/LibraryDashboard.jsx @@ -1,11 +1,11 @@ import { useState, useMemo } from 'react'; -import { useKoboStore } from '../stores/koboStore.js'; +import { useKoboStore } from '../stores/koboStore.ts'; import { CoverPreview } from '../components/backup/CoverPreview.jsx'; -import { Container } from '../components/layout/Container.jsx'; -import { Card } from '../components/common/Card.jsx'; -import { Button } from '../components/common/Button.jsx'; -import { formatDuration } from '../utils/formatters.js'; -import { exportToObsidianZip, exportToAnkiCsv } from '../utils/export.js'; +import { Container } from '../components/layout/Container.tsx'; +import { Card } from '../components/common/Card.tsx'; +import { Button } from '../components/common/Button.tsx'; +import { formatDuration } from '../utils/formatters.ts'; +import { exportToObsidianZip, exportToAnkiCsv } from '../utils/export.ts'; export function LibraryDashboard({ onNavigate }) { const books = useKoboStore((state) => state.books); @@ -16,7 +16,7 @@ export function LibraryDashboard({ onNavigate }) { // Tab state: 'books' | 'annotations' | 'stats' const [activeTab, setActiveTab] = useState('books'); - + // Search & Filter state const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); // all | reading | finished | unread @@ -117,11 +117,11 @@ export function LibraryDashboard({ onNavigate }) { const handleExportBookNotes = (book, bookAnns) => { if (!bookAnns || bookAnns.length === 0) return; - + let markdown = `# Notes: ${book.Title}\n`; markdown += `By ${book.Author}\n\n`; markdown += `*Exported from Kobo Backup Manager on ${new Date().toLocaleDateString()}*\n\n---\n\n`; - + bookAnns.forEach((ann, idx) => { markdown += `### Highlight ${idx + 1}\n`; if (ann.HighlightedText) markdown += `> ${ann.HighlightedText}\n\n`; @@ -147,18 +147,15 @@ export function LibraryDashboard({ onNavigate }) { {/* Dashboard Header */}
    -

    - Library Dashboard -

    +

    Library Dashboard

    - Connected: {device?.model || 'Kobo Device'} (Firmware: {device?.firmwareVersion || 'Unknown'}) + Connected:{' '} + {device?.model || 'Kobo Device'}{' '} + (Firmware: {device?.firmwareVersion || 'Unknown'})

    -
    @@ -202,14 +199,14 @@ export function LibraryDashboard({ onNavigate }) { {activeTab === 'books' && (
    {/* Filters bar */} -
    +
    setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-kobo-cream-dark rounded-lg focus:ring-2 focus:ring-kobo-accent/50 focus:border-kobo-accent outline-none font-body text-sm" + className="w-full pl-10 pr-4 py-2 border border-kobo-cream-dark rounded-lg focus:ring-2 focus:ring-kobo-accent/50 focus:border-kobo-accent outline-hidden font-body text-sm" /> 🔍
    @@ -218,7 +215,7 @@ export function LibraryDashboard({ onNavigate }) { setSortBy(e.target.value)} - className="border border-kobo-cream-dark rounded-lg px-3 py-2 text-sm font-body bg-white outline-none focus:ring-2 focus:ring-kobo-accent/50" + className="border border-kobo-cream-dark rounded-lg px-3 py-2 text-sm font-body bg-white outline-hidden focus:ring-2 focus:ring-kobo-accent/50" > @@ -241,7 +238,7 @@ export function LibraryDashboard({ onNavigate }) { {/* Books Grid */} {filteredBooks.length === 0 ? ( -
    +

    No books found matching your criteria.

    ) : ( @@ -249,10 +246,11 @@ export function LibraryDashboard({ onNavigate }) { {filteredBooks.map((book) => { const bookAnns = annotationsByBook[book.ContentID] || []; return ( -
    setSelectedBook(book)} - className="group flex flex-col cursor-pointer" + className="group flex flex-col cursor-pointer text-left focus-visible-ring rounded-md" > {book.Title} -

    - {book.Author} -

    +

    {book.Author}

    - + {/* Reading Progress Indicator */}
    @@ -289,7 +285,7 @@ export function LibraryDashboard({ onNavigate }) { )}
    -
    + ); })}
    @@ -301,7 +297,7 @@ export function LibraryDashboard({ onNavigate }) { {activeTab === 'annotations' && (
    {annotations.length > 0 && ( - +

    🎒 Advanced Notes Exporter @@ -332,10 +328,12 @@ export function LibraryDashboard({ onNavigate }) { )} {annotations.length === 0 ? ( -
    +
    ✍️

    No annotations found

    -

    Make sure you have highlights or notes in your books.

    +

    + Make sure you have highlights or notes in your books. +

    ) : (
    @@ -346,7 +344,7 @@ export function LibraryDashboard({ onNavigate }) { return (
    -
    +
    {book.Title}

    -

    - {book.Author} -

    +

    {book.Author}

    {bookAnns.length} highlights @@ -370,18 +366,25 @@ export function LibraryDashboard({ onNavigate }) { {/* Scrolling highlights list */}
    {bookAnns.map((ann) => ( -
    +

    {ann.HighlightedText}

    {ann.Note && ( -
    - My Note +
    + + My Note +

    {ann.Note}

    )}
    - {ann.DateCreated ? new Date(ann.DateCreated).toLocaleDateString() : ''} + + {ann.DateCreated ? new Date(ann.DateCreated).toLocaleDateString() : ''} +
    -
    ✍️ @@ -529,12 +530,15 @@ export function LibraryDashboard({ onNavigate }) { const author = book.Author || 'Unknown'; acc[author] = (acc[author] || 0) + 1; return acc; - }, {}) + }, {}), ) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([author, count]) => ( -
    +
    {author} {count} {count === 1 ? 'book' : 'books'} @@ -549,7 +553,7 @@ export function LibraryDashboard({ onNavigate }) { {/* Book details Modal */} {selectedBook && ( -
    +
    {/* Modal Header */}
    @@ -577,7 +581,7 @@ export function LibraryDashboard({ onNavigate }) { author={selectedBook.Author} />
    - + {/* Progress bar */}
    @@ -599,9 +603,7 @@ export function LibraryDashboard({ onNavigate }) {

    {selectedBook.Title}

    -

    - {selectedBook.Author} -

    +

    {selectedBook.Author}

    @@ -619,9 +621,7 @@ export function LibraryDashboard({ onNavigate }) {
    ISBN - - {selectedBook.ISBN || 'N/A'} - + {selectedBook.ISBN || 'N/A'}
    Publisher @@ -635,15 +635,12 @@ export function LibraryDashboard({ onNavigate }) {

    - Highlights & Notes ({ (annotationsByBook[selectedBook.ContentID] || []).length }) + Highlights & Notes ({(annotationsByBook[selectedBook.ContentID] || []).length})

    {(annotationsByBook[selectedBook.ContentID] || []).length > 0 && (
    diff --git a/src/pages/PrivacyPolicy.jsx b/src/pages/PrivacyPolicy.jsx index bb9c012..73a37d2 100644 --- a/src/pages/PrivacyPolicy.jsx +++ b/src/pages/PrivacyPolicy.jsx @@ -2,17 +2,12 @@ * Privacy Policy page - GDPR compliant (March 2026) */ -import PropTypes from 'prop-types'; -import { Button } from '../components/common/Button.jsx'; +import { Button } from '../components/common/Button.tsx'; export function PrivacyPolicy({ onNavigate }) { return (
    - @@ -25,9 +20,9 @@ export function PrivacyPolicy({ onNavigate }) {

    Introduction

    - Kobo Backup Manager is a free, non-commercial, open-source web application - that helps Kobo e-reader users backup their libraries. This Privacy Policy explains how data - is processed when you use this tool. + Kobo Backup Manager is a free, non-commercial, open-source web application that + helps Kobo e-reader users backup their libraries. This Privacy Policy explains how data is processed + when you use this tool.

    Key principles: @@ -46,8 +41,8 @@ export function PrivacyPolicy({ onNavigate }) {

    About This Project

    - This is a personal open-source project with no commercial entity behind it. - The complete source code is available on GitHub, where you can: + This is a personal open-source project with no commercial entity behind it. The + complete source code is available on GitHub, where you can:

    • Review the code to verify privacy claims
    • @@ -56,8 +51,8 @@ export function PrivacyPolicy({ onNavigate }) {

    GDPR note: Although this is a non-commercial project, we still respect EU - residents' privacy rights. The minimal analytics described below serves only to understand - basic usage; it is never used for commercial gain. + residents' privacy rights. The minimal analytics described below serves only to understand basic + usage; it is never used for commercial gain.

    @@ -65,10 +60,10 @@ export function PrivacyPolicy({ onNavigate }) {

    Information We Collect

    -

    1. Library Data (Processed Locally Only)

    -

    - When you create or restore a backup, the app processes: -

    +

    + 1. Library Data (Processed Locally Only) +

    +

    When you create or restore a backup, the app processes:

    • Kobo database contents (books, highlights, bookmarks, reading progress)
    • Book files and metadata
    • @@ -76,15 +71,13 @@ export function PrivacyPolicy({ onNavigate }) {

    - ✓ This data is processed entirely within your browser using JavaScript and WebAssembly. - It is never transmitted to any server or third party. + ✓ This data is processed entirely within your browser using JavaScript and WebAssembly. It is + never transmitted to any server or third party.

    2. Browser Local Storage

    -

    - The app stores the following in your browser's local storage: -

    +

    The app stores the following in your browser's local storage:

    • Backup history (dates, file sizes, statistics)
    • Application preferences and settings
    • @@ -94,21 +87,29 @@ export function PrivacyPolicy({ onNavigate }) { This data stays on your device and can be deleted by clearing browser data.

      -

      3. Vercel Analytics (Anonymous Page Views)

      +

      + 3. Vercel Analytics (Anonymous Page Views) +

      The site uses Vercel Analytics to collect anonymous, aggregated page-view statistics. Vercel Analytics is designed with privacy as a core feature:

        -
      • No cookies — does not set any cookies
      • -
      • No fingerprinting — does not track individual users across sessions
      • -
      • No personal data — IP addresses are not stored; only aggregated counts are kept
      • +
      • + No cookies — does not set any cookies +
      • +
      • + No fingerprinting — does not track individual users across sessions +
      • +
      • + No personal data — IP addresses are not stored; only aggregated counts are kept +
      • Data collected: page URL, referrer, approximate country, browser/OS family, screen size

      - Vercel Analytics does not access, collect, or transmit your Kobo library data, backup - files, or any personal reading information. It only counts page visits at an aggregate level. + Vercel Analytics does not access, collect, or transmit your Kobo library data, backup files, or + any personal reading information. It only counts page visits at an aggregate level.

    @@ -117,39 +118,48 @@ export function PrivacyPolicy({ onNavigate }) {

    How We Use Your Information

      -
    • Service Operation: To provide backup and restore functionality (local only)
    • -
    • Service Improvement: To understand which features are used most (via aggregate analytics)
    • -
    • Browser Compatibility: To ensure the app works across different browsers
    • +
    • + Service Operation: To provide backup and restore functionality (local only) +
    • +
    • + Service Improvement: To understand which features are used most (via aggregate + analytics) +
    • +
    • + Browser Compatibility: To ensure the app works across different browsers +
    {/* Legal Basis for Processing (GDPR) */}
    -

    Legal Basis for Processing (GDPR)

    +

    + Legal Basis for Processing (GDPR) +

    • - Legitimate Interests (Art. 6(1)(f)): Vercel Analytics provides only - aggregate, cookieless statistics that cannot identify individuals. This minimal processing - serves our legitimate interest in understanding how the app is used, and does not override - your privacy rights. + Legitimate Interests (Art. 6(1)(f)): Vercel Analytics provides only aggregate, + cookieless statistics that cannot identify individuals. This minimal processing serves our + legitimate interest in understanding how the app is used, and does not override your privacy + rights.
    • - Contract Performance: Local data processing is necessary to provide the - backup service you request. + Contract Performance: Local data processing is necessary to provide the backup + service you request.

    - Because Vercel Analytics is cookieless and processes no personal data, it does not require - a prior consent banner under the ePrivacy Directive. + Because Vercel Analytics is cookieless and processes no personal data, it does not require a prior + consent banner under the ePrivacy Directive.

    {/* Data Sharing and Third Parties */}
    -

    Data Sharing and Third Parties

    -

    - The only third-party service this site sends data to is: -

    +

    + Data Sharing and Third Parties +

    +

    The only third-party service this site sends data to is:

    Vercel Analytics

    @@ -157,12 +167,12 @@ export function PrivacyPolicy({ onNavigate }) { Purpose: Anonymous, aggregate page-view statistics

    - Data collected: Page URL, referrer, approximate country, browser family, - screen size — no personal identifiers, no cookies + Data collected: Page URL, referrer, approximate country, browser family, screen + size — no personal identifiers, no cookies

    - Data processor: Vercel Inc. (San Francisco, CA, USA) — EU Standard - Contractual Clauses apply for transfers outside the EEA + Data processor: Vercel Inc. (San Francisco, CA, USA) — EU Standard Contractual + Clauses apply for transfers outside the EEA

    Privacy Policy:{' '} @@ -179,8 +189,8 @@ export function PrivacyPolicy({ onNavigate }) {

    - Your Kobo library data, backup files, and reading information are NEVER shared with any - third party. They are processed only within your browser and never leave your device. + Your Kobo library data, backup files, and reading information are NEVER shared with any third + party. They are processed only within your browser and never leave your device.

    @@ -189,9 +199,16 @@ export function PrivacyPolicy({ onNavigate }) {

    Data Retention

      -
    • Local Storage: Retained until you clear browser data
    • -
    • Vercel Analytics: Aggregated data retained for up to 1 year per Vercel's policy; no personal data is stored
    • -
    • Library Data: Never retained on any server — processed locally only
    • +
    • + Local Storage: Retained until you clear browser data +
    • +
    • + Vercel Analytics: Aggregated data retained for up to 1 year per Vercel's policy; + no personal data is stored +
    • +
    • + Library Data: Never retained on any server — processed locally only +
    @@ -202,28 +219,49 @@ export function PrivacyPolicy({ onNavigate }) { If you are in the European Economic Area (EEA), you have the following rights:

      -
    • Right to Access: All data is stored locally in your browser — you have full access
    • -
    • Right to Erasure: Clear your browser's local storage to delete all app data
    • -
    • Right to Data Portability: Your backup files are portable ZIP files under your full control
    • -
    • Right to Object: You can block Vercel Analytics by using a browser extension that blocks network requests to va.vercel-scripts.com
    • -
    • Right to Lodge a Complaint: Contact your local data protection supervisory authority
    • +
    • + Right to Access: All data is stored locally in your browser — you have full + access +
    • +
    • + Right to Erasure: Clear your browser's local storage to delete all app data +
    • +
    • + Right to Data Portability: Your backup files are portable ZIP files under your + full control +
    • +
    • + Right to Object: You can block Vercel Analytics by using a browser extension that + blocks network requests to{' '} + va.vercel-scripts.com +
    • +
    • + Right to Lodge a Complaint: Contact your local data protection supervisory + authority +
    -

    - ✓ Managing Your Data (No Contact Needed): -

    +

    ✓ Managing Your Data (No Contact Needed):

      -
    • Local Storage: Clear via browser settings (e.g., Chrome → Settings → Privacy → Clear browsing data)
    • -
    • Analytics: Block via an ad/tracker blocker, or use browser developer tools to block va.vercel-scripts.com
    • -
    • Backups: You control the ZIP files — delete from your computer as needed
    • +
    • + Local Storage: Clear via browser settings (e.g., Chrome → Settings → Privacy → + Clear browsing data) +
    • +
    • + Analytics: Block via an ad/tracker blocker, or use browser developer tools to + block va.vercel-scripts.com +
    • +
    • + Backups: You control the ZIP files — delete from your computer as needed +

    - Since this is a non-commercial open-source project with no central data controller, there - is no email or support entity to contact for data requests. For questions about the app, - please open a GitHub Issue. + Since this is a non-commercial open-source project with no central data controller, there is no + email or support entity to contact for data requests. For questions about the app, please open a + GitHub Issue.

    @@ -231,13 +269,13 @@ export function PrivacyPolicy({ onNavigate }) {

    International Data Transfers

    - Vercel Analytics data may be transferred to and processed in the United States. Vercel Inc. - relies on Standard Contractual Clauses (SCCs) approved by the European Commission for - transfers of personal data from the EEA. + Vercel Analytics data may be transferred to and processed in the United States. Vercel Inc. relies + on Standard Contractual Clauses (SCCs) approved by the European Commission for transfers of personal + data from the EEA.

    - Because Vercel Analytics collects no personal data (no IP addresses stored, no cookies), - the practical privacy risk of these transfers is negligible. + Because Vercel Analytics collects no personal data (no IP addresses stored, no cookies), the + practical privacy risk of these transfers is negligible.

    @@ -248,9 +286,16 @@ export function PrivacyPolicy({ onNavigate }) { This website does not use any cookies for tracking or analytics.

      -
    • No tracking cookies — Vercel Analytics is entirely cookieless
    • -
    • No third-party cookies — no advertising or retargeting networks
    • -
    • Local Storage only — app preferences (backup history, settings, banner dismissal) are stored in browser local storage, which is not a cookie and is not shared with any third party
    • +
    • + No tracking cookies — Vercel Analytics is entirely cookieless +
    • +
    • + No third-party cookies — no advertising or retargeting networks +
    • +
    • + Local Storage only — app preferences (backup history, settings, banner dismissal) + are stored in browser local storage, which is not a cookie and is not shared with any third party +
    @@ -294,11 +339,13 @@ export function PrivacyPolicy({ onNavigate }) { {/* Changes to This Policy */}
    -

    Changes to This Privacy Policy

    +

    + Changes to This Privacy Policy +

    - We may update this Privacy Policy to reflect changes in our practices or legal requirements. - The "Last Updated" date at the top indicates when the policy was last revised. Continued use - of the Service after changes constitutes acceptance of the updated policy. + We may update this Privacy Policy to reflect changes in our practices or legal requirements. The + "Last Updated" date at the top indicates when the policy was last revised. Continued use of the + Service after changes constitutes acceptance of the updated policy.

    @@ -323,21 +370,15 @@ export function PrivacyPolicy({ onNavigate }) {

    - Note: This is a personal open-source project with no commercial entity or formal data - controller. There is no support email or helpdesk. Community support is provided via GitHub. + Note: This is a personal open-source project with no commercial entity or formal data controller. + There is no support email or helpdesk. Community support is provided via GitHub.

    - +
    ); } - -PrivacyPolicy.propTypes = { - onNavigate: PropTypes.func.isRequired, -}; diff --git a/src/pages/UserGuide.jsx b/src/pages/UserGuide.jsx index 7534680..39ce756 100644 --- a/src/pages/UserGuide.jsx +++ b/src/pages/UserGuide.jsx @@ -2,17 +2,12 @@ * User Guide page - comprehensive instructions for using Kobo Backup Manager */ -import PropTypes from 'prop-types'; -import { Button } from '../components/common/Button.jsx'; +import { Button } from '../components/common/Button.tsx'; export function UserGuide({ onNavigate }) { return (
    - @@ -23,12 +18,13 @@ export function UserGuide({ onNavigate }) {

    Getting Started

    - Kobo Backup Manager is a privacy-first web application that helps you backup and restore - your Kobo e-reader library entirely in your browser. No data is sent to any server. + Kobo Backup Manager is a privacy-first web application that helps you backup and restore your Kobo + e-reader library entirely in your browser. No data is sent to any server.

    - Requirements: Chrome 86+, Edge 86+, or any Chromium-based browser with File System Access API support. + Requirements: Chrome 86+, Edge 86+, or any Chromium-based browser with File System Access API + support.

    @@ -41,21 +37,25 @@ export function UserGuide({ onNavigate }) {

    Step 1: Connect Your Kobo

    - Connect your Kobo e-reader to your computer using a USB cable. Wait for it to be recognized - as a storage device. + Connect your Kobo e-reader to your computer using a USB cable. Wait for it to be recognized as a + storage device.

    -

    Step 2: Start Backup Wizard

    +

    + Step 2: Start Backup Wizard +

    - Click the "Create Backup" button on the home page. The backup wizard will guide you through - the process. + Click the "Create Backup" button on the home page. The backup wizard will guide you through the + process.

    -

    Step 3: Select Kobo Device Folder

    +

    + Step 3: Select Kobo Device Folder +

    Click "Select Kobo Device" and navigate to your Kobo's root folder. This is typically named "KOBOeReader" in your file system. @@ -71,10 +71,10 @@ export function UserGuide({ onNavigate }) {

    -

    Step 5: Choose What to Backup

    -

    - Select which data you want to include in your backup: -

    +

    + Step 5: Choose What to Backup +

    +

    Select which data you want to include in your backup:

    • Books and documents
    • Reading progress and statistics
    • @@ -112,10 +112,10 @@ export function UserGuide({ onNavigate }) {
    -

    Step 2: Start Restore Wizard

    -

    - Click the "Restore Backup" button on the home page. -

    +

    + Step 2: Start Restore Wizard +

    +

    Click the "Restore Backup" button on the home page.

    @@ -126,17 +126,17 @@ export function UserGuide({ onNavigate }) {
    -

    Step 4: Select Target Device

    -

    - Choose your Kobo device folder as the restore destination. -

    +

    + Step 4: Select Target Device +

    +

    Choose your Kobo device folder as the restore destination.

    Step 5: Review and Restore

    - Review what will be restored, then click "Restore Backup" to complete the process. Safely - eject your Kobo device when done. + Review what will be restored, then click "Restore Backup" to complete the process. Safely eject + your Kobo device when done.

    @@ -165,7 +165,9 @@ export function UserGuide({ onNavigate }) {
    -

    Can't find KoboReader.sqlite?

    +

    + Can't find KoboReader.sqlite? +

    Make sure your Kobo is connected and recognized as a storage device. Look for a folder named ".kobo" inside your Kobo device - the database file is located there. @@ -175,8 +177,8 @@ export function UserGuide({ onNavigate }) {

    Browser not supported?

    - This app requires Chrome 86+, Edge 86+, or another Chromium-based browser. Safari and Firefox - do not yet support the File System Access API needed for direct device access. + This app requires Chrome 86+, Edge 86+, or another Chromium-based browser. Safari and Firefox do + not yet support the File System Access API needed for direct device access.

    @@ -213,25 +215,16 @@ export function UserGuide({ onNavigate }) {

We use Google Analytics and ContentSquare solely to improve the service. See our{' '} - - {' '}for details. + {' '} + for details.

- +
); } - -UserGuide.propTypes = { - onNavigate: PropTypes.func.isRequired, -}; diff --git a/src/stores/koboStore.js b/src/stores/koboStore.js deleted file mode 100644 index c0f6db1..0000000 --- a/src/stores/koboStore.js +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Zustand store for global state management - */ - -import { create } from 'zustand'; -import { persist } from 'zustand/middleware'; - -export const useKoboStore = create( - persist( - (set, get) => ({ - // Device state - device: null, - books: [], - annotations: [], - stats: null, - deviceHandle: null, - - // Backup history (persisted to localStorage) - backups: [], - - // UI state - currentPage: 'home', - - // Actions - setDevice: (device) => set({ device }), - - setBooks: (books) => set({ books }), - - setAnnotations: (annotations) => set({ annotations }), - - setStats: (stats) => set({ stats }), - - setDeviceHandle: (deviceHandle) => set({ deviceHandle }), - - addBackup: (backup) => - set((state) => ({ - backups: [ - { - id: `backup_${Date.now()}`, - ...backup, - }, - ...state.backups, - ].slice(0, 20), // Keep only last 20 backups in history - })), - - removeBackup: (id) => - set((state) => ({ - backups: state.backups.filter((b) => b.id !== id), - })), - - setCurrentPage: (page) => set({ currentPage: page }), - - clearDevice: () => - set({ - device: null, - books: [], - annotations: [], - stats: null, - deviceHandle: null, - }), - - reset: () => - set({ - device: null, - books: [], - annotations: [], - stats: null, - deviceHandle: null, - currentPage: 'home', - }), - }), - { - name: 'kobo-backup-storage', - // Only persist backups, not device data - partialize: (state) => ({ - backups: state.backups, - }), - } - ) -); diff --git a/src/stores/koboStore.ts b/src/stores/koboStore.ts new file mode 100644 index 0000000..226e717 --- /dev/null +++ b/src/stores/koboStore.ts @@ -0,0 +1,72 @@ +/** + * Global state (Zustand). Only the backup history is persisted. + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { + BackupHistoryEntry, + DeviceInfo, + KoboAnnotation, + KoboBook, + ReadingStats, +} from '../types/kobo.ts'; + +export type Page = 'home' | 'dashboard' | 'backup' | 'restore' | 'history' | 'guide' | 'faq' | 'privacy'; + +interface KoboState { + device: DeviceInfo | null; + books: KoboBook[]; + annotations: KoboAnnotation[]; + stats: ReadingStats | null; + deviceHandle: FileSystemDirectoryHandle | null; + backups: BackupHistoryEntry[]; + currentPage: Page; + + setDevice: (device: DeviceInfo | null) => void; + setBooks: (books: KoboBook[]) => void; + setAnnotations: (annotations: KoboAnnotation[]) => void; + setStats: (stats: ReadingStats | null) => void; + setDeviceHandle: (deviceHandle: FileSystemDirectoryHandle | null) => void; + addBackup: (backup: Omit) => void; + removeBackup: (id: string) => void; + setCurrentPage: (page: Page) => void; + clearDevice: () => void; +} + +const MAX_HISTORY = 20; + +export const useKoboStore = create()( + persist( + (set) => ({ + device: null, + books: [], + annotations: [], + stats: null, + deviceHandle: null, + backups: [], + currentPage: 'home', + + setDevice: (device) => set({ device }), + setBooks: (books) => set({ books }), + setAnnotations: (annotations) => set({ annotations }), + setStats: (stats) => set({ stats }), + setDeviceHandle: (deviceHandle) => set({ deviceHandle }), + + addBackup: (backup) => + set((state) => ({ + backups: [{ id: `backup_${Date.now()}`, ...backup }, ...state.backups].slice(0, MAX_HISTORY), + })), + + removeBackup: (id) => set((state) => ({ backups: state.backups.filter((b) => b.id !== id) })), + + setCurrentPage: (page) => set({ currentPage: page }), + + clearDevice: () => set({ device: null, books: [], annotations: [], stats: null, deviceHandle: null }), + }), + { + name: 'kobo-backup-storage', + partialize: (state) => ({ backups: state.backups }), + }, + ), +); diff --git a/src/styles/animations.css b/src/styles/animations.css deleted file mode 100644 index 22d5aa5..0000000 --- a/src/styles/animations.css +++ /dev/null @@ -1,191 +0,0 @@ -/* Animation Keyframes */ - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes slideUp { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -@keyframes shimmer { - 0% { - transform: translateX(-100%); - } - 100% { - transform: translateX(100%); - } -} - -@keyframes pulse { - 0%, 100% { - opacity: 1; - } - 50% { - opacity: 0.5; - } -} - -@keyframes bounce { - 0%, 100% { - transform: translateY(0); - } - 50% { - transform: translateY(-10px); - } -} - -@keyframes spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@keyframes scaleIn { - from { - opacity: 0; - transform: scale(0.9); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes progressPulse { - 0% { - transform: scaleX(0.95); - opacity: 0.7; - } - 50% { - transform: scaleX(1); - opacity: 1; - } - 100% { - transform: scaleX(0.95); - opacity: 0.7; - } -} - -/* Utility Classes */ -.animate-fade-in { - animation: fadeIn 0.5s ease-out; -} - -.animate-slide-up { - animation: slideUp 0.3s ease-out; -} - -.animate-slide-down { - animation: slideDown 0.3s ease-out; -} - -.animate-shimmer { - animation: shimmer 2s infinite; -} - -.animate-pulse { - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; -} - -.animate-bounce { - animation: bounce 1s ease-in-out 3; -} - -.animate-spin { - animation: spin 1s linear infinite; -} - -.animate-scale-in { - animation: scaleIn 0.3s ease-out; -} - -.animate-progress-pulse { - animation: progressPulse 2s ease-in-out infinite; -} - -/* Transition utilities */ -.transition-all-smooth { - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.transition-transform { - transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -.transition-colors-fast { - transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease; -} - -/* Hover animations */ -.hover-lift { - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.hover-lift:hover { - transform: translateY(-2px); -} - -.hover-scale { - transition: transform 0.2s ease; -} - -.hover-scale:hover { - transform: scale(1.05); -} - -/* Loading spinner */ -.spinner { - border: 3px solid var(--kobo-cream-dark); - border-top: 3px solid var(--kobo-accent); - border-radius: 50%; - animation: spin 1s linear infinite; -} - -/* Progress bar shimmer effect */ -.progress-shimmer { - position: relative; - overflow: hidden; -} - -.progress-shimmer::after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient( - 90deg, - transparent, - rgba(255, 255, 255, 0.3), - transparent - ); - animation: shimmer 2s infinite; -} diff --git a/src/styles/globals.css b/src/styles/globals.css index 07e15b1..83535c9 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,140 +1,153 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - /* Primary Colors */ - --kobo-cream: #F5F1E8; - --kobo-cream-dark: #E8E3D8; - --kobo-accent: #D4A574; - --kobo-accent-dark: #B8895E; - - /* Neutrals */ - --kobo-dark: #2C2416; - --kobo-gray: #6B675E; - --kobo-gray-light: #A8A49A; - - /* Status Colors */ - --kobo-success: #7FB069; - --kobo-warning: #F4A261; - --kobo-error: #E63946; - --kobo-info: #457B9D; - - /* Overlays */ - --kobo-overlay: rgba(44, 36, 22, 0.4); - --kobo-glass: rgba(245, 241, 232, 0.8); - - /* Spacing (8px grid) */ - --space-1: 0.5rem; /* 8px */ - --space-2: 1rem; /* 16px */ - --space-3: 1.5rem; /* 24px */ - --space-4: 2rem; /* 32px */ - --space-5: 3rem; /* 48px */ - --space-6: 4rem; /* 64px */ - --space-7: 6rem; /* 96px */ - --space-8: 8rem; /* 128px */ - - /* Font families */ - --font-display: 'Inter', system-ui, -apple-system, sans-serif; - --font-body: 'Inter', system-ui, -apple-system, sans-serif; +@import 'tailwindcss'; + +@theme { + --color-kobo-cream: #f5f1e8; + --color-kobo-cream-dark: #e8e3d8; + --color-kobo-accent: #d4a574; + --color-kobo-accent-dark: #b8895e; + --color-kobo-dark: #2c2416; + --color-kobo-gray: #6b675e; + --color-kobo-gray-light: #a8a49a; + --color-kobo-success: #7fb069; + --color-kobo-warning: #f4a261; + --color-kobo-error: #e63946; + --color-kobo-info: #457b9d; + --color-kobo-overlay: rgba(44, 36, 22, 0.4); + --color-kobo-glass: rgba(245, 241, 232, 0.8); + + --font-display: Oswald, system-ui, sans-serif; + --font-body: Poppins, system-ui, sans-serif; + + --text-6xl: 4rem; + --text-6xl--line-height: 1; + --text-5xl: 3rem; + --text-5xl--line-height: 1; + --text-4xl: 2.25rem; + --text-4xl--line-height: 1.1; + --text-3xl: 1.875rem; + --text-3xl--line-height: 1.2; + --text-2xl: 1.5rem; + --text-2xl--line-height: 1.3; + --text-xl: 1.25rem; + --text-xl--line-height: 1.4; + --text-lg: 1.125rem; + --text-lg--line-height: 1.5; + --text-base: 1rem; + --text-base--line-height: 1.5; + --text-sm: 0.875rem; + --text-sm--line-height: 1.5; + --text-xs: 0.75rem; + --text-xs--line-height: 1.5; + + --animate-fade-in: fade-in 0.5s ease-out; + --animate-slide-down: slide-down 0.3s ease-out; + --animate-pulse: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; + --animate-bounce: bounce 1s ease-in-out 3; + + --shadow-elevated: 0 8px 30px rgb(0 0 0 / 0.06); + --shadow-elevated-hover: 0 12px 40px rgb(0 0 0 / 0.08); + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + @keyframes slide-down { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } + @keyframes bounce { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } + } } @layer base { - * { - @apply border-kobo-gray-light; + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-kobo-gray-light); } body { @apply bg-kobo-cream text-kobo-dark font-body antialiased; } - h1, h2, h3, h4, h5, h6 { + h1, + h2, + h3, + h4, + h5, + h6 { @apply font-display font-bold; } h1 { @apply text-6xl; } - h2 { @apply text-5xl; } - h3 { @apply text-4xl; } - h4 { @apply text-3xl; } - h5 { @apply text-2xl; } - h6 { @apply text-xl; } -} - -@layer utilities { - .focus-visible-ring { - @apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kobo-accent focus-visible:ring-offset-2; - } - - .sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; - } - - /* Scrollbar styling */ - .custom-scrollbar::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - .custom-scrollbar::-webkit-scrollbar-track { - @apply bg-kobo-cream-dark rounded-full; - } - .custom-scrollbar::-webkit-scrollbar-thumb { - @apply bg-kobo-gray-light rounded-full; + @media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } - - .custom-scrollbar::-webkit-scrollbar-thumb:hover { - @apply bg-kobo-gray; - } -} - -/* Glassmorphism effect */ -.glass { - backdrop-filter: blur(12px); - background: var(--kobo-glass); - border: 1px solid rgba(255, 255, 255, 0.2); } -/* Card elevation */ -.card-elevated { - box-shadow: 0 8px 30px rgb(0 0 0 / 0.06); - transition: box-shadow 0.3s ease; +@utility focus-visible-ring { + @apply focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-kobo-accent focus-visible:ring-offset-2; } -.card-elevated:hover { - box-shadow: 0 12px 40px rgb(0 0 0 / 0.08); +@utility transition-all-smooth { + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } -/* Gradient overlays */ -.gradient-overlay::before { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient(to bottom right, var(--kobo-cream) / 40%, transparent); - pointer-events: none; +@utility spinner { + border: 3px solid var(--color-kobo-cream-dark); + border-top: 3px solid var(--color-kobo-accent); + border-radius: 50%; + animation: var(--animate-spin); } diff --git a/src/test/builders.ts b/src/test/builders.ts new file mode 100644 index 0000000..b4bbe38 --- /dev/null +++ b/src/test/builders.ts @@ -0,0 +1,147 @@ +/** + * Builders for synthetic Kobo databases and backup ZIPs used by tests. + */ +import { ZipWriter, BlobWriter, TextReader, Uint8ArrayReader } from '@zip.js/zip.js'; +import initSqlJs from 'sql.js'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { BackupMetadata } from '../types/kobo.ts'; + +export const FIXTURES_DIR = + process.env.KOBUP_FIXTURES_DIR || fileURLToPath(new URL('../../fixtures/', import.meta.url)); + +/** Path of a private fixture, or null when it is not available (e.g. CI). */ +export function fixture(name: string): string | null { + const path = join(FIXTURES_DIR, name); + return existsSync(path) ? path : null; +} + +export interface BookSeed { + contentId: string; + title: string; + author?: string; + percent?: number; + readStatus?: number; + imageId?: string; + time?: number; + mime?: string; + isbn?: string; + publisher?: string; + dateLastRead?: string; +} + +export interface BookmarkSeed { + id: string; + volumeId: string; + text?: string | null; + note?: string | null; + date?: string; +} + +export interface DbSeedOptions { + dbVersion?: number; + users?: number; +} + +export async function buildKoboDb( + books: BookSeed[] = [], + bookmarks: BookmarkSeed[] = [], + { dbVersion, users = 0 }: DbSeedOptions = {}, +): Promise> { + const SQL = await initSqlJs(); + const db = new SQL.Database(); + db.run(`CREATE TABLE content ( + ContentID TEXT, Title TEXT, Attribution TEXT, Description TEXT, Publisher TEXT, + Series TEXT, SeriesNumber TEXT, ISBN TEXT, Language TEXT, ___PercentRead INTEGER, + ReadStatus INTEGER, DateCreated TEXT, DateLastRead TEXT, ImageId TEXT, + TimeSpentReading INTEGER, MimeType TEXT, ContentType INTEGER, IsDownloaded TEXT, + BookTitle TEXT, ChapterIDBookmarked TEXT, ___FileSize INTEGER + );`); + db.run(`CREATE TABLE Bookmark ( + BookmarkID TEXT, VolumeID TEXT, ContentID TEXT, Text TEXT, Annotation TEXT, DateCreated TEXT, + DateModified TEXT, StartContainerPath TEXT, StartOffset INTEGER, + EndContainerPath TEXT, EndOffset INTEGER, Hidden TEXT, Type TEXT, Color INTEGER + );`); + db.run(`CREATE TABLE Shelf (CreationDate TEXT, Id TEXT, InternalName TEXT, LastModified TEXT, Name TEXT, + Type TEXT, _IsDeleted TEXT, _IsVisible TEXT, _IsSynced TEXT, _SyncTime TEXT, LastAccessed TEXT);`); + db.run( + `CREATE TABLE ShelfContent (ShelfName TEXT, ContentId TEXT, DateModified TEXT, _IsDeleted TEXT, _IsSynced TEXT);`, + ); + db.run(`CREATE TABLE user (UserID TEXT, UserKey TEXT, UserDisplayName TEXT, UserEmail TEXT, ___DeviceID TEXT, + AuthToken TEXT, RefreshToken TEXT, KoboAccessToken TEXT);`); + for (let i = 0; i < users; i++) { + db.run( + `INSERT INTO user VALUES (?, 'key', 'Reader', 'reader@example.com', 'dev', 'auth', 'refresh', 'access')`, + [`user-${i}`], + ); + } + if (dbVersion !== undefined) { + db.run('CREATE TABLE DbVersion (version INTEGER)'); + db.run('INSERT INTO DbVersion VALUES (?)', [dbVersion]); + } + for (const b of books) { + db.run( + `INSERT INTO content (ContentID,Title,Attribution,___PercentRead,ReadStatus,ImageId,TimeSpentReading,MimeType,ContentType,IsDownloaded,BookTitle,ISBN,Publisher,DateLastRead) + VALUES (?,?,?,?,?,?,?,?,6,'true',NULL,?,?,?)`, + [ + b.contentId, + b.title, + b.author ?? 'Auth', + b.percent ?? 0, + b.readStatus ?? 0, + b.imageId ?? 'img1', + b.time ?? 0, + b.mime ?? 'application/epub+zip', + b.isbn ?? '111', + b.publisher ?? 'Pub', + b.dateLastRead ?? '2026-01-01T00:00:00Z', + ], + ); + } + for (const m of bookmarks) { + db.run( + `INSERT INTO Bookmark (BookmarkID,VolumeID,ContentID,Text,Annotation,DateCreated) VALUES (?,?,?,?,?,?)`, + [m.id, m.volumeId, m.volumeId, m.text ?? null, m.note ?? null, m.date ?? '2026-01-01T00:00:00Z'], + ); + } + const buf = db.export() as Uint8Array; + db.close(); + return buf; +} + +export interface ZipSeedEntry { + name: string; + content?: string; +} + +export async function buildBackupZip({ + dbBytes, + metadata, + bookEntries = [], + extraEntries = [], +}: { + dbBytes: Uint8Array; + metadata: Partial; + bookEntries?: ZipSeedEntry[]; + extraEntries?: ZipSeedEntry[]; +}): Promise { + const zw = new ZipWriter(new BlobWriter('application/zip')); + await zw.add('backup-metadata.json', new TextReader(JSON.stringify(metadata))); + await zw.add('KoboReader.sqlite', new Uint8ArrayReader(dbBytes)); + for (const e of [...bookEntries, ...extraEntries]) + await zw.add(e.name, new TextReader(e.content ?? 'EPUBDATA')); + return zw.close(); +} + +export function meta(over: Partial = {}): BackupMetadata { + return { + version: '1.0.0', + created: '2026-02-23T20:29:00.000Z', + generator: 'test', + statistics: { totalBooks: 1, totalAnnotations: 0 }, + device: { model: 'Kobo', firmwareVersion: '4.0', schemaVersion: 0 }, + options: {}, + ...over, + }; +} diff --git a/src/test/memoryFs.ts b/src/test/memoryFs.ts new file mode 100644 index 0000000..1c2cfb4 --- /dev/null +++ b/src/test/memoryFs.ts @@ -0,0 +1,131 @@ +/** + * Minimal in-memory implementation of the File System Access API handles, + * so restore/backup code can run end-to-end under node in tests. + */ + +type Data = Blob | ArrayBuffer | ArrayBufferView | string; + +function namedError(name: string, message: string): Error { + const e = new Error(message); + e.name = name; + return e; +} + +class MemWritable { + private readonly fh: MemFileHandle; + constructor(fh: MemFileHandle) { + this.fh = fh; + } + async write(d: Data) { + this.fh.data = d; + } + async close() {} + async abort() {} +} + +export class MemFileHandle { + readonly kind = 'file' as const; + readonly name: string; + data: Data | null = null; + constructor(name: string) { + this.name = name; + } + async createWritable() { + return new MemWritable(this); + } + async getFile(): Promise { + return new File(this.data == null ? [] : [this.data as BlobPart], this.name); + } +} + +export class MemDirHandle { + readonly kind = 'directory' as const; + readonly name: string; + readonly dirs = new Map(); + readonly files = new Map(); + + constructor(name = 'root') { + this.name = name; + } + + // Mirror the browser API, which throws on "." / ".." / separators. + private check(n: string) { + if (n === '.' || n === '..' || /[\\/]/.test(n)) throw new TypeError(`Name "${n}" is not allowed.`); + } + + async getDirectoryHandle(name: string, opts: { create?: boolean } = {}) { + this.check(name); + const existing = this.dirs.get(name); + if (existing) return existing; + if (!opts.create) throw namedError('NotFoundError', `not found: ${name}`); + const d = new MemDirHandle(name); + this.dirs.set(name, d); + return d; + } + + async getFileHandle(name: string, opts: { create?: boolean } = {}) { + this.check(name); + const existing = this.files.get(name); + if (existing) return existing; + if (!opts.create) throw namedError('NotFoundError', `not found: ${name}`); + const f = new MemFileHandle(name); + this.files.set(name, f); + return f; + } + + async removeEntry(name: string, opts: { recursive?: boolean } = {}) { + this.check(name); + if (this.files.delete(name)) return; + const d = this.dirs.get(name); + if (!d) throw namedError('NotFoundError', `not found: ${name}`); + if (!opts.recursive && (d.dirs.size || d.files.size)) + throw namedError('InvalidModificationError', 'not empty'); + this.dirs.delete(name); + } + + async *values(): AsyncGenerator { + yield* this.dirs.values(); + yield* this.files.values(); + } + + async *entries(): AsyncGenerator<[string, MemDirHandle | MemFileHandle]> { + for await (const v of this.values()) yield [v.name, v]; + } + + // ---- test helpers ---- + dir(path: string): MemDirHandle | undefined { + return path.split('/').reduce((h, seg) => (seg ? h?.dirs.get(seg) : h), this); + } + + fileAt(path: string): MemFileHandle | null { + const parts = path.split('/'); + const name = parts.pop()!; + return this.dir(parts.join('/'))?.files.get(name) ?? null; + } + + hasFile(path: string): boolean { + return !!this.fileAt(path); + } + + async writeText(path: string, content: Data): Promise { + const parts = path.split('/'); + const name = parts.pop()!; + const dir = await parts.reduce>( + async (parent, p) => (await parent).getDirectoryHandle(p, { create: true }), + Promise.resolve(this), + ); + (await dir.getFileHandle(name, { create: true })).data = content; + } + + /** Cast to the DOM type expected by production code. */ + get handle(): FileSystemDirectoryHandle { + return this as unknown as FileSystemDirectoryHandle; + } +} + +/** A fresh device with an empty `.kobo` folder. */ +export async function freshDevice(): Promise { + const dev = new MemDirHandle('device'); + await dev.getDirectoryHandle('.kobo', { create: true }); + return dev; +} diff --git a/src/test/sqlJsNode.ts b/src/test/sqlJsNode.ts new file mode 100644 index 0000000..18baa1f --- /dev/null +++ b/src/test/sqlJsNode.ts @@ -0,0 +1,18 @@ +/** + * sql.js locates its WASM through a bundler URL in the browser build; under + * node we point it at the installed binary. Import this module (via vi.mock + * factory) BEFORE anything that loads sql.js. + */ +import { fileURLToPath } from 'node:url'; +import type { SqlJsConfig, SqlJsStatic } from 'sql.js'; + +export const SQL_WASM_PATH = fileURLToPath( + new URL('../../node_modules/sql.js/dist/sql-wasm.wasm', import.meta.url), +); + +export async function sqlJsNodeMock( + importOriginal: () => Promise<{ default: (cfg?: SqlJsConfig) => Promise }>, +) { + const real = (await importOriginal()).default; + return { default: (cfg: SqlJsConfig = {}) => real({ ...cfg, locateFile: () => SQL_WASM_PATH }) }; +} diff --git a/src/types/kobo.ts b/src/types/kobo.ts new file mode 100644 index 0000000..d82da30 --- /dev/null +++ b/src/types/kobo.ts @@ -0,0 +1,151 @@ +/** + * Domain types shared across the app (Kobo database rows, scan results, + * backup metadata, progress/error state). + */ + +export interface KoboBook { + ContentID: string; + Title: string | null; + Author: string; + Description: string | null; + Publisher: string | null; + Series: string | null; + SeriesNumber: string | null; + ISBN: string | null; + Language: string | null; + Progress: number; + ReadStatus: number | null; + DateCreated: Date | null; + DateLastRead: Date | null; + FilePath: string | null; + CoverId: string | null; + TimeSpentReading: number; + MimeType: string | null; +} + +export interface KoboAnnotation { + BookmarkID: string; + VolumeID: string; + HighlightedText: string | null; + Note: string | null; + DateCreated: Date | null; + DateModified: Date | null; + StartContainerPath?: string | null; + StartOffset?: number | null; + EndContainerPath?: string | null; + EndOffset?: number | null; + BookTitle: string; + Author: string; +} + +export interface ReadingStats { + totalBooks: number; + booksStarted: number; + booksFinished: number; + currentlyReading: number; + totalMinutesRead: number; + averageProgress: number; + uniqueAuthors: number; +} + +export interface DeviceInfo { + model: string; + firmwareVersion: string; + databaseVersion: string; + schemaVersion: number; +} + +export interface KoboCollection { + Id: string; + Name: string; + InternalName: string | null; + Type: string | null; + CreationDate: Date | null; + LastModified: Date | null; +} + +export interface ExtractedData { + books: KoboBook[]; + annotations: KoboAnnotation[]; + stats: ReadingStats; + deviceInfo: DeviceInfo; + collections: KoboCollection[]; + databaseSize: number; +} + +/** A book file found on the device. */ +export interface BookFileEntry { + handle: FileSystemFileHandle; + /** Path relative to the device root, e.g. "Author/Title.kepub.epub". */ + path: string; + name: string; + size: number; +} + +export interface ScanResult { + books: KoboBook[]; + annotations: KoboAnnotation[]; + stats: ReadingStats; + deviceInfo: DeviceInfo; + bookFiles: BookFileEntry[]; + database: ArrayBuffer; +} + +export interface BackupOptions { + includeBooks: boolean; + includeAnnotations: boolean; + includeProgress: boolean; + includeSettings: boolean; +} + +export interface BackupMetadata { + version: string; + created: string; + generator: string; + device: { + model: string; + firmwareVersion: string; + schemaVersion: number; + }; + statistics: { + totalBooks: number; + totalAnnotations: number; + totalSize?: number; + booksStarted?: number; + booksFinished?: number; + totalReadingTime?: number; + }; + options?: Partial; + integrity?: { + databaseChecksum: string; + filesChecked: number; + fileChecksums: Record; + errors: { file: string; error: string }[]; + }; + compatibility?: { minAppVersion: string; supportedDevices: string[] }; +} + +export interface ProgressState { + stage: string; + percent: number; + filesProcessed: number; + totalFiles: number; +} + +export type ProgressCallback = (stage: string, percent: number, filesProcessed?: number) => void; + +export interface UiError { + title: string; + message: string; + code: string; +} + +export interface BackupHistoryEntry { + id: string; + filename: string; + created: string; + size: number; + deviceModel: string; + bookCount: number; + annotationCount: number; +} diff --git a/src/utils/backup.js b/src/utils/backup.js deleted file mode 100644 index 8cc08d3..0000000 --- a/src/utils/backup.js +++ /dev/null @@ -1,261 +0,0 @@ -/** - * Backup creation utilities - streaming approach via client-zip. - * - * WHY: JSZip buffers ALL files simultaneously. For a 4 GB library - * this peaks at ~10 GB and throws RangeError: Array buffer allocation failed. - * client-zip streams one file at a time to disk, so peak RAM = 1 book file. - */ - -import { downloadZip } from 'client-zip'; -import { saveFile } from './fileSystem.js'; -import { BackupError, ERROR_CODES } from './errors.js'; - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -function buildMetadata(koboData, options, extra = {}) { - const { - includeBooks = true, includeAnnotations = true, - includeProgress = true, includeSettings = false, - } = options; - const dev = koboData.deviceInfo || {}; - return { - version: '1.0.0', - created: new Date().toISOString(), - generator: 'Kobo Backup Manager v1.0', - device: { - model: dev.model || 'Unknown', - firmwareVersion: dev.firmwareVersion || 'Unknown', - schemaVersion: dev.schemaVersion ?? 0, - }, - statistics: { - totalBooks: koboData.books?.length || 0, - totalAnnotations: koboData.annotations?.length || 0, - totalSize: koboData.totalSize || 0, - booksStarted: koboData.stats?.booksStarted || 0, - booksFinished: koboData.stats?.booksFinished || 0, - totalReadingTime: koboData.stats?.totalMinutesRead || 0, - }, - options: { includeBooks, includeAnnotations, includeProgress, includeSettings }, - integrity: { - databaseChecksum: extra.databaseChecksum || 'unavailable', - filesChecked: koboData.bookFiles?.length || 0, - // Per-file checksums not computed in streaming mode (would need two reads per file) - fileChecksums: {}, - // Shared mutable array filled by the generator; JSON.stringify captures - // the final state when the metadata entry is yielded (after all books). - errors: extra.errors || [], - }, - compatibility: { minAppVersion: '1.0.0', supportedDevices: ['all'] }, - }; -} - -async function* generateZipEntries(koboData, options, onProgress, metadata) { - const { includeBooks = true, includeAnnotations = true } = options; - - // 1. SQLite database (small, safe to buffer) - onProgress('Preparing backup...', 0); - yield { - name: 'KoboReader.sqlite', - input: new Blob([koboData.database]), - size: koboData.database.byteLength, - }; - - // 2. Book files — ONE at a time. - // handle.getFile() returns a File (Blob subclass) lazily; client-zip - // streams it to disk before requesting the next file. - // Peak memory = max single-file size, not the whole library. - if (includeBooks && koboData.bookFiles?.length > 0) { - const total = koboData.bookFiles.length; - for (let i = 0; i < total; i++) { - const bf = koboData.bookFiles[i]; - try { - const file = await bf.handle.getFile(); - yield { - name: 'books/' + (bf.path || bf.name), - input: file, - size: file.size, - lastModified: new Date(file.lastModified), - }; - } catch (err) { - console.warn('[BACKUP] Skipping unreadable file:', bf.name, err); - metadata.integrity.errors.push({ file: bf.name, error: err.message }); - } - onProgress( - 'Adding books (' + (i + 1) + '/' + total + ')...', - 10 + ((i + 1) / total) * 72, - i + 1, - ); - } - } - - // 3. Annotations - if (includeAnnotations && koboData.annotations?.length > 0) { - onProgress('Exporting annotations...', 85); - yield { - name: 'annotations/all-annotations.md', - input: exportAnnotationsAsMarkdown(koboData.annotations), - }; - } - - // 4. Metadata — AFTER all books so the errors array is complete when JSON.stringify runs - onProgress('Adding metadata...', 90); - yield { name: 'backup-metadata.json', input: JSON.stringify(metadata, null, 2) }; - - // 5. README - yield { name: 'README.txt', input: generateReadme(metadata) }; - - onProgress('Finalizing...', 95); -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * PRIMARY PATH: stream backup directly to disk via showSaveFilePicker. - * Peak memory: ~1 book file at a time, regardless of library size. - * - * fileHandle MUST be obtained from window.showSaveFilePicker() synchronously - * inside the click handler (before any await) to satisfy the user-gesture - * requirement. BackupWizard is responsible for this. - * - * @param {object} koboData - scan result; bookFiles items must have .handle (FileSystemFileHandle) - * @param {FileSystemFileHandle} fileHandle - writable handle from showSaveFilePicker - * @param {string} filename - filename chosen by the user - * @param {object} options - * @returns {Promise<{filename, size, metadata}>} - */ -export async function streamBackupToDisk(koboData, fileHandle, filename, options = {}) { - const { onProgress = () => {} } = options; - try { - const databaseChecksum = await calculateChecksum(koboData.database); - const metadata = buildMetadata(koboData, options, { databaseChecksum, errors: [] }); - const entries = generateZipEntries(koboData, options, onProgress, metadata); - const zipResponse = downloadZip(entries); - - const writable = await fileHandle.createWritable(); - try { - await zipResponse.body.pipeTo(writable); - } catch (err) { - await writable.abort().catch(() => {}); - throw err; - } - - onProgress('Backup complete', 100); - const savedFile = await fileHandle.getFile(); - return { filename, size: savedFile.size, metadata }; - } catch (error) { - console.error('[BACKUP] Streaming failed:', error); - throw new BackupError('Failed to create backup', ERROR_CODES.BACKUP_FAILED, { originalError: error }); - } -} - -/** - * FALLBACK PATH: buffer the ZIP as a Blob then trigger a browser download. - * Used when showSaveFilePicker is unavailable (Firefox, Safari). - * WARNING: may still OOM for libraries > ~2 GB on these browsers. - * - * @param {object} koboData - * @param {object} options - * @returns {Promise<{blob, filename, size, metadata}>} - */ -export async function createBackupBlob(koboData, options = {}) { - const { onProgress = () => {} } = options; - try { - const databaseChecksum = await calculateChecksum(koboData.database); - const metadata = buildMetadata(koboData, options, { databaseChecksum, errors: [] }); - const entries = generateZipEntries(koboData, options, onProgress, metadata); - const blob = await downloadZip(entries).blob(); - onProgress('Backup complete', 100); - return { blob, filename: generateBackupFilename(), size: blob.size, metadata }; - } catch (error) { - console.error('[BACKUP] Blob creation failed:', error); - throw new BackupError('Failed to create backup', ERROR_CODES.BACKUP_FAILED, { originalError: error }); - } -} - -export async function saveBackup(blob, filename) { - try { - return await saveFile(blob, { fileName: filename, extensions: ['.zip'] }); - } catch (error) { - throw new BackupError('Failed to save backup file', ERROR_CODES.BACKUP_FAILED, { originalError: error }); - } -} - -/** Generate the suggested filename. Exported so BackupWizard can use it before the save dialog. */ -export function generateBackupFilename() { - return 'kobo_backup_' + new Date().toISOString().split('T')[0] + '.zip'; -} - -export async function calculateChecksum(data) { - try { - const buffer = data instanceof Blob ? await data.arrayBuffer() : data; - const hb = await crypto.subtle.digest('SHA-256', buffer); - return 'sha256:' + Array.from(new Uint8Array(hb)).map(b => b.toString(16).padStart(2, '0')).join(''); - } catch { - return 'unavailable'; - } -} - -export function estimateBackupSize(koboData) { - let size = 0; - if (koboData.database) size += koboData.database.byteLength || koboData.database.size || 0; - if (koboData.bookFiles) koboData.bookFiles.forEach(f => { size += f.size || f.blob?.size || 0; }); - return Math.floor((size + 100 * 1024) * 0.9); -} - -// --------------------------------------------------------------------------- -// Private formatting helpers -// --------------------------------------------------------------------------- - -function exportAnnotationsAsMarkdown(annotations) { - let md = '# Kobo Annotations Export\n\n'; - md += 'Exported on: ' + new Date().toLocaleString() + '\n\nTotal annotations: ' + annotations.length + '\n\n---\n\n'; - const byBook = {}; - annotations.forEach(a => { - const t = a.BookTitle || 'Unknown Book'; - (byBook[t] = byBook[t] || []).push(a); - }); - Object.entries(byBook).forEach(([title, list]) => { - md += '## ' + title + '\n\n'; - if (list[0]?.Author) md += '*by ' + list[0].Author + '*\n\n'; - list.forEach((a, idx) => { - md += '### Annotation ' + (idx + 1) + '\n\n'; - if (a.HighlightedText) md += '> ' + a.HighlightedText + '\n\n'; - if (a.Note) md += '**Note:** ' + a.Note + '\n\n'; - if (a.DateCreated) md += '*Created: ' + new Date(a.DateCreated).toLocaleString() + '*\n\n'; - md += '---\n\n'; - }); - }); - return md; -} - -function generateReadme(m) { - return [ - 'Kobo Backup Archive', '====================', '', - 'Created: ' + new Date(m.created).toLocaleString(), - 'Generator: ' + m.generator, '', - 'Device Information', '------------------', - 'Model: ' + m.device.model, 'Firmware: ' + m.device.firmwareVersion, '', - 'Backup Statistics', '-----------------', - 'Total Books: ' + m.statistics.totalBooks, - 'Total Annotations: ' + m.statistics.totalAnnotations, - 'Books Started: ' + m.statistics.booksStarted, - 'Books Finished: ' + m.statistics.booksFinished, - 'Total Reading Time: ' + Math.floor(m.statistics.totalReadingTime / 60) + ' hours', '', - 'Contents', '--------', - '- KoboReader.sqlite: Your Kobo database with all reading data', - '- books/: All your ebook files', - '- annotations/: Human-readable export of your highlights and notes', - '- backup-metadata.json: Technical metadata about this backup', '', - 'How to Restore', '--------------', - '1. Open Kobo Backup Manager', '2. Click "Restore Backup"', - '3. Select this ZIP file', '4. Follow the wizard to restore to your Kobo device', '', - 'Privacy Note', '------------', - 'This backup was created entirely in your browser.', - 'No data was sent to any server.', - 'Keep this file safe and private.', '', - ].join('\n'); -} diff --git a/src/utils/backup.ts b/src/utils/backup.ts new file mode 100644 index 0000000..cba311e --- /dev/null +++ b/src/utils/backup.ts @@ -0,0 +1,291 @@ +/** + * Backup creation - streaming approach via client-zip. + * + * WHY: JSZip buffers ALL files simultaneously. For a 4 GB library this peaks + * at ~10 GB and throws "RangeError: Array buffer allocation failed". + * client-zip streams one file at a time to disk, so peak RAM = 1 book file. + */ + +import { downloadZip } from 'client-zip'; +import { downloadBlob } from './fileSystem.ts'; +import { BackupError, ERROR_CODES } from './errors.ts'; +import type { + BackupMetadata, + BackupOptions, + KoboAnnotation, + ProgressCallback, + ScanResult, +} from '../types/kobo.ts'; + +export const APP_VERSION = __APP_VERSION__; + +export interface BackupResult { + filename: string; + size: number; + metadata: BackupMetadata; +} + +export type BackupRunOptions = Partial & { onProgress?: ProgressCallback }; + +type IntegrityErrors = NonNullable['errors']; + +export interface ZipEntry { + name: string; + input: Blob | string; + size?: number; + lastModified?: Date; +} + +function buildMetadata( + koboData: ScanResult, + options: Partial, + databaseChecksum: string, + errors: IntegrityErrors, +): BackupMetadata { + const { + includeBooks = true, + includeAnnotations = true, + includeProgress = true, + includeSettings = false, + } = options; + const dev = koboData.deviceInfo; + return { + version: '1.0.0', + created: new Date().toISOString(), + generator: `KoBup v${APP_VERSION}`, + device: { + model: dev?.model || 'Unknown', + firmwareVersion: dev?.firmwareVersion || 'Unknown', + schemaVersion: dev?.schemaVersion ?? 0, + }, + statistics: { + totalBooks: koboData.books?.length || 0, + totalAnnotations: koboData.annotations?.length || 0, + totalSize: koboData.bookFiles?.reduce((sum, f) => sum + (f.size || 0), 0) || 0, + booksStarted: koboData.stats?.booksStarted || 0, + booksFinished: koboData.stats?.booksFinished || 0, + totalReadingTime: koboData.stats?.totalMinutesRead || 0, + }, + options: { includeBooks, includeAnnotations, includeProgress, includeSettings }, + integrity: { + databaseChecksum, + filesChecked: koboData.bookFiles?.length || 0, + // Per-file checksums are not computed in streaming mode (would need two reads per file) + fileChecksums: {}, + // Shared mutable array filled by the generator; JSON.stringify captures + // the final state when the metadata entry is yielded (after all books). + errors, + }, + compatibility: { minAppVersion: '1.0.0', supportedDevices: ['all'] }, + }; +} + +async function* generateZipEntries( + koboData: ScanResult, + options: Partial, + onProgress: ProgressCallback, + metadata: BackupMetadata, +): AsyncGenerator { + const { includeBooks = true, includeAnnotations = true } = options; + + // 1. SQLite database (small, safe to buffer) + onProgress('Preparing backup...', 0); + yield { + name: 'KoboReader.sqlite', + input: new Blob([koboData.database]), + size: koboData.database.byteLength, + }; + + // 2. Book files - ONE at a time. handle.getFile() returns a lazy File; + // client-zip streams it to disk before requesting the next one. + if (includeBooks && koboData.bookFiles?.length > 0) { + const total = koboData.bookFiles.length; + for (let i = 0; i < total; i++) { + const bf = koboData.bookFiles[i]!; + try { + const file = await bf.handle.getFile(); + yield { + name: `books/${bf.path || bf.name}`, + input: file, + size: file.size, + lastModified: new Date(file.lastModified), + }; + } catch (err) { + console.warn('[BACKUP] Skipping unreadable file:', bf.name, err); + metadata.integrity?.errors.push({ + file: bf.name, + error: err instanceof Error ? err.message : String(err), + }); + } + onProgress(`Adding books (${i + 1}/${total})...`, 10 + ((i + 1) / total) * 72, i + 1); + } + } + + // 3. Annotations (human-readable export) + if (includeAnnotations && koboData.annotations?.length > 0) { + onProgress('Exporting annotations...', 85); + yield { + name: 'annotations/all-annotations.md', + input: exportAnnotationsAsMarkdown(koboData.annotations), + }; + } + + // 4. Metadata - AFTER all books so the errors array is complete + onProgress('Adding metadata...', 90); + yield { name: 'backup-metadata.json', input: JSON.stringify(metadata, null, 2) }; + + // 5. README + yield { name: 'README.txt', input: generateReadme(metadata) }; + + onProgress('Finalizing...', 95); +} + +/** + * PRIMARY PATH: stream the backup directly to disk via showSaveFilePicker. + * Peak memory: ~1 book file at a time, regardless of library size. + * + * fileHandle MUST be obtained from window.showSaveFilePicker() inside the + * click handler to satisfy the user-gesture requirement (BackupWizard does this). + */ +export async function streamBackupToDisk( + koboData: ScanResult, + fileHandle: FileSystemFileHandle, + filename: string, + options: BackupRunOptions = {}, +): Promise { + const { onProgress = () => {} } = options; + try { + const metadata = buildMetadata(koboData, options, await calculateChecksum(koboData.database), []); + const zipResponse = downloadZip(generateZipEntries(koboData, options, onProgress, metadata)); + + const writable = await fileHandle.createWritable(); + try { + await zipResponse.body!.pipeTo(writable); + } catch (err) { + await writable.abort().catch(() => {}); + throw err; + } + + onProgress('Backup complete', 100); + const savedFile = await fileHandle.getFile(); + return { filename, size: savedFile.size, metadata }; + } catch (error) { + console.error('[BACKUP] Streaming failed:', error); + throw new BackupError('Failed to create backup', ERROR_CODES.BACKUP_FAILED, { originalError: error }); + } +} + +/** + * FALLBACK PATH: buffer the ZIP as a Blob then trigger a browser download. + * WARNING: may OOM for very large libraries. + */ +export async function createBackupBlob( + koboData: ScanResult, + options: BackupRunOptions = {}, +): Promise { + const { onProgress = () => {} } = options; + try { + const metadata = buildMetadata(koboData, options, await calculateChecksum(koboData.database), []); + const blob = await downloadZip(generateZipEntries(koboData, options, onProgress, metadata)).blob(); + onProgress('Backup complete', 100); + return { blob, filename: generateBackupFilename(), size: blob.size, metadata }; + } catch (error) { + console.error('[BACKUP] Blob creation failed:', error); + throw new BackupError('Failed to create backup', ERROR_CODES.BACKUP_FAILED, { originalError: error }); + } +} + +export function saveBackup(blob: Blob, filename: string): { filename: string; size: number } { + try { + return downloadBlob(blob, filename); + } catch (error) { + throw new BackupError('Failed to save backup file', ERROR_CODES.BACKUP_FAILED, { originalError: error }); + } +} + +/** Suggested filename. Exported so BackupWizard can use it before the save dialog. */ +export function generateBackupFilename(date = new Date()): string { + return `kobo_backup_${date.toISOString().split('T')[0]}.zip`; +} + +export async function calculateChecksum(data: ArrayBuffer | Uint8Array | Blob): Promise { + try { + const buffer = data instanceof Blob ? await data.arrayBuffer() : data; + const hash = await crypto.subtle.digest('SHA-256', buffer); + return `sha256:${Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('')}`; + } catch { + return 'unavailable'; + } +} + +export function estimateBackupSize(koboData: Pick): number { + let size = koboData.database?.byteLength || 0; + for (const f of koboData.bookFiles ?? []) size += f.size || 0; + return Math.floor((size + 100 * 1024) * 0.9); +} + +function exportAnnotationsAsMarkdown(annotations: KoboAnnotation[]): string { + let md = '# Kobo Annotations Export\n\n'; + md += `Exported on: ${new Date().toLocaleString()}\n\nTotal annotations: ${annotations.length}\n\n---\n\n`; + const byBook = new Map(); + for (const a of annotations) { + const title = a.BookTitle || 'Unknown Book'; + byBook.set(title, [...(byBook.get(title) ?? []), a]); + } + for (const [title, list] of byBook) { + md += `## ${title}\n\n`; + if (list[0]?.Author) md += `*by ${list[0].Author}*\n\n`; + list.forEach((a, idx) => { + md += `### Annotation ${idx + 1}\n\n`; + if (a.HighlightedText) md += `> ${a.HighlightedText}\n\n`; + if (a.Note) md += `**Note:** ${a.Note}\n\n`; + if (a.DateCreated) md += `*Created: ${new Date(a.DateCreated).toLocaleString()}*\n\n`; + md += '---\n\n'; + }); + } + return md; +} + +function generateReadme(m: BackupMetadata): string { + return [ + 'Kobo Backup Archive', + '====================', + '', + `Created: ${new Date(m.created).toLocaleString()}`, + `Generator: ${m.generator}`, + '', + 'Device Information', + '------------------', + `Model: ${m.device.model}`, + `Firmware: ${m.device.firmwareVersion}`, + '', + 'Backup Statistics', + '-----------------', + `Total Books: ${m.statistics.totalBooks}`, + `Total Annotations: ${m.statistics.totalAnnotations}`, + `Books Started: ${m.statistics.booksStarted ?? 0}`, + `Books Finished: ${m.statistics.booksFinished ?? 0}`, + `Total Reading Time: ${Math.floor((m.statistics.totalReadingTime ?? 0) / 60)} hours`, + '', + 'Contents', + '--------', + '- KoboReader.sqlite: Your Kobo database with all reading data', + '- books/: All your ebook files', + '- annotations/: Human-readable export of your highlights and notes', + '- backup-metadata.json: Technical metadata about this backup', + '', + 'How to Restore', + '--------------', + '1. Open KoBup (https://www.kobup.org)', + '2. Click "Restore Backup"', + '3. Select this ZIP file', + '4. Follow the wizard to restore to your Kobo device', + '', + 'Privacy Note', + '------------', + 'This backup was created entirely in your browser.', + 'No data was sent to any server.', + 'Keep this file safe and private.', + '', + ].join('\n'); +} diff --git a/src/utils/errors.js b/src/utils/errors.js deleted file mode 100644 index 5024b7d..0000000 --- a/src/utils/errors.js +++ /dev/null @@ -1,195 +0,0 @@ -/** - * Custom error classes for Kobo Backup Manager - */ - -/** - * Base error class for backup operations - */ -export class BackupError extends Error { - constructor(message, code, details = {}) { - super(message); - this.name = 'BackupError'; - this.code = code; - this.details = details; - } -} - -/** - * Error for restore operations - */ -export class RestoreError extends Error { - constructor(message, code, details = {}) { - super(message); - this.name = 'RestoreError'; - this.code = code; - this.details = details; - } -} - -/** - * Error for validation failures - */ -export class ValidationError extends Error { - constructor(message, code, details = {}) { - super(message); - this.name = 'ValidationError'; - this.code = code; - this.details = details; - } -} - -/** - * Error for file system operations - */ -export class FileSystemError extends Error { - constructor(message, code, details = {}) { - super(message); - this.name = 'FileSystemError'; - this.code = code; - this.details = details; - } -} - -/** - * Error for database operations - */ -export class DatabaseError extends Error { - constructor(message, code, details = {}) { - super(message); - this.name = 'DatabaseError'; - this.code = code; - this.details = details; - } -} - -/** - * Error codes for different failure scenarios - */ -export const ERROR_CODES = { - // Backup errors - BACKUP_FAILED: 'BACKUP_FAILED', - BACKUP_CANCELLED: 'BACKUP_CANCELLED', - BACKUP_INVALID_DEVICE: 'BACKUP_INVALID_DEVICE', - BACKUP_NO_BOOKS: 'BACKUP_NO_BOOKS', - BACKUP_SIZE_LIMIT: 'BACKUP_SIZE_LIMIT', - - // Restore errors - RESTORE_FAILED: 'RESTORE_FAILED', - RESTORE_CANCELLED: 'RESTORE_CANCELLED', - RESTORE_INVALID_FILE: 'RESTORE_INVALID_FILE', - RESTORE_CORRUPTED: 'RESTORE_CORRUPTED', - RESTORE_INCOMPATIBLE: 'RESTORE_INCOMPATIBLE', - - // File system errors - FS_PERMISSION_DENIED: 'FS_PERMISSION_DENIED', - FS_NOT_FOUND: 'FS_NOT_FOUND', - FS_READ_ERROR: 'FS_READ_ERROR', - FS_WRITE_ERROR: 'FS_WRITE_ERROR', - FS_NOT_SUPPORTED: 'FS_NOT_SUPPORTED', - - // Database errors - DB_OPEN_FAILED: 'DB_OPEN_FAILED', - DB_QUERY_FAILED: 'DB_QUERY_FAILED', - DB_CORRUPTED: 'DB_CORRUPTED', - DB_NOT_FOUND: 'DB_NOT_FOUND', - - // Validation errors - VALIDATION_FAILED: 'VALIDATION_FAILED', - VALIDATION_INVALID_FORMAT: 'VALIDATION_INVALID_FORMAT', - VALIDATION_MISSING_FIELD: 'VALIDATION_MISSING_FIELD', - - // Browser compatibility errors - BROWSER_NOT_SUPPORTED: 'BROWSER_NOT_SUPPORTED', - BROWSER_FEATURE_MISSING: 'BROWSER_FEATURE_MISSING', -}; - -/** - * Get recovery suggestions for error codes - * @param {string} errorCode - Error code - * @returns {string[]} Array of recovery suggestions - */ -export function getRecoverySteps(errorCode) { - const steps = { - [ERROR_CODES.BACKUP_INVALID_DEVICE]: [ - 'Make sure your Kobo is connected via USB', - 'Unlock your Kobo device and tap "Connect"', - 'Select the Kobo drive (usually named "KOBOeReader")', - 'Try reconnecting the USB cable', - ], - [ERROR_CODES.BACKUP_NO_BOOKS]: [ - 'Make sure you have books on your Kobo', - 'Check that the books are sideloaded (not from the Kobo store)', - 'Try syncing your Kobo with the Kobo app first', - ], - [ERROR_CODES.RESTORE_INVALID_FILE]: [ - 'Make sure you selected a valid backup ZIP file', - 'Check that the backup file is not corrupted', - 'Try creating a new backup', - ], - [ERROR_CODES.FS_PERMISSION_DENIED]: [ - 'Grant permission when the browser asks', - 'Make sure the Kobo device is not write-protected', - 'Try closing other programs that might be using the Kobo', - ], - [ERROR_CODES.FS_NOT_SUPPORTED]: [ - 'Use Google Chrome 86+ or Microsoft Edge 86+', - 'Update your browser to the latest version', - 'Try the drag-and-drop fallback method', - ], - [ERROR_CODES.DB_CORRUPTED]: [ - 'Try restarting your Kobo device', - 'Check if you can read books on the Kobo normally', - 'Consider restoring the device to factory settings (as last resort)', - ], - [ERROR_CODES.BROWSER_NOT_SUPPORTED]: [ - 'Use Google Chrome 86+ or Microsoft Edge 86+', - 'Update your browser to the latest version', - 'Enable required browser features in settings', - ], - }; - - return steps[errorCode] || [ - 'Try the operation again', - 'Check your internet connection', - 'Restart the browser', - 'Contact support if the problem persists', - ]; -} - -/** - * Get user-friendly error message - * @param {Error} error - Error object - * @returns {{title: string, message: string, recovery: string[]}} Formatted error info - */ -export function formatError(error) { - let title = 'Error'; - let message = error.message || 'An unexpected error occurred'; - let recovery = []; - - if (error instanceof BackupError) { - title = 'Backup Failed'; - recovery = getRecoverySteps(error.code); - } else if (error instanceof RestoreError) { - title = 'Restore Failed'; - recovery = getRecoverySteps(error.code); - } else if (error instanceof FileSystemError) { - title = 'File System Error'; - recovery = getRecoverySteps(error.code); - } else if (error instanceof DatabaseError) { - title = 'Database Error'; - recovery = getRecoverySteps(error.code); - } else if (error instanceof ValidationError) { - title = 'Validation Error'; - recovery = getRecoverySteps(error.code); - } else if (error.name === 'AbortError') { - title = 'Operation Cancelled'; - message = 'You cancelled the operation'; - recovery = ['Try again when ready']; - } else if (error.name === 'NotAllowedError') { - title = 'Permission Denied'; - message = 'You need to grant permission to access files'; - recovery = getRecoverySteps(ERROR_CODES.FS_PERMISSION_DENIED); - } - - return { title, message, recovery }; -} diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000..8319ae6 --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,63 @@ +/** + * Custom error classes for KoBup. + */ + +export const ERROR_CODES = { + BACKUP_FAILED: 'BACKUP_FAILED', + BACKUP_CANCELLED: 'BACKUP_CANCELLED', + BACKUP_INVALID_DEVICE: 'BACKUP_INVALID_DEVICE', + + RESTORE_FAILED: 'RESTORE_FAILED', + RESTORE_CANCELLED: 'RESTORE_CANCELLED', + RESTORE_INVALID_FILE: 'RESTORE_INVALID_FILE', + RESTORE_CORRUPTED: 'RESTORE_CORRUPTED', + RESTORE_INCOMPATIBLE: 'RESTORE_INCOMPATIBLE', + + FS_PERMISSION_DENIED: 'FS_PERMISSION_DENIED', + FS_NOT_FOUND: 'FS_NOT_FOUND', + FS_READ_ERROR: 'FS_READ_ERROR', + FS_WRITE_ERROR: 'FS_WRITE_ERROR', + FS_NOT_SUPPORTED: 'FS_NOT_SUPPORTED', + + DB_OPEN_FAILED: 'DB_OPEN_FAILED', + DB_QUERY_FAILED: 'DB_QUERY_FAILED', + DB_WRITE_ERROR: 'DB_WRITE_ERROR', +} as const; + +export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; + +export type ErrorDetails = Record & { originalError?: unknown }; + +/** Base class: every app error carries a machine-readable code and details. */ +export class AppError extends Error { + readonly code: ErrorCode; + readonly details: ErrorDetails; + + constructor(message: string, code: ErrorCode, details: ErrorDetails = {}) { + super(message); + this.name = new.target.name; + this.code = code; + this.details = details; + } +} + +export class BackupError extends AppError {} +export class RestoreError extends AppError {} +export class FileSystemError extends AppError {} +export class DatabaseError extends AppError {} + +/** Best-effort human readable message for any thrown value. */ +export function errorMessage(error: unknown, fallback = 'Unknown error'): string { + if (error instanceof AppError) { + const original = error.details.originalError; + const cause = + original instanceof Error && original.message !== error.message ? `: ${original.message}` : ''; + return `${error.message}${cause}`; + } + if (error instanceof Error) return error.message || fallback; + return typeof error === 'string' ? error : fallback; +} + +export function errorCode(error: unknown, fallback: string): string { + return error instanceof AppError ? error.code : fallback; +} diff --git a/src/utils/export.js b/src/utils/export.js deleted file mode 100644 index a6f5dbb..0000000 --- a/src/utils/export.js +++ /dev/null @@ -1,133 +0,0 @@ -import { downloadZip } from 'client-zip'; - -/** - * Generate Obsidian Markdown file content for a book - * @param {object} book - Book object - * @param {Array} bookAnns - Book annotations - * @returns {string} Markdown content with YAML frontmatter - */ -export function generateObsidianMarkdown(book, bookAnns) { - const frontmatter = [ - '---', - `title: "${(book.Title || '').replace(/"/g, '\\"')}"`, - `author: "${(book.Author || '').replace(/"/g, '\\"')}"`, - `publisher: "${(book.Publisher || '').replace(/"/g, '\\"')}"`, - `isbn: "${book.ISBN || ''}"`, - `progress: ${book.Progress || 0}`, - `time_spent_minutes: ${book.TimeSpentReading || 0}`, - 'source: Kobo', - `exported_at: ${new Date().toISOString()}`, - '---', - '' - ].join('\n'); - - let content = `# ${book.Title}\n`; - content += `**Author:** ${book.Author || 'Unknown'}\n`; - if (book.Publisher) content += `**Publisher:** ${book.Publisher}\n`; - if (book.ISBN) content += `**ISBN:** ${book.ISBN}\n`; - content += `**Reading Progress:** ${book.Progress || 0}%\n\n`; - content += `## Highlights & Notes\n\n`; - - bookAnns.forEach((ann, idx) => { - content += `### Highlight ${idx + 1}\n`; - if (ann.HighlightedText) { - content += `> ${ann.HighlightedText.replace(/\n/g, '\n> ')}\n\n`; - } - if (ann.Note) { - content += `**Note:** ${ann.Note}\n\n`; - } - if (ann.DateCreated) { - content += `*Added on ${new Date(ann.DateCreated).toLocaleString()}*\n\n`; - } - content += `---\n\n`; - }); - - return frontmatter + content; -} - -/** - * Export all annotated books to Obsidian ZIP archive - * @param {Array} books - All books - * @param {object} annotationsByBook - Map of book ContentID to array of annotations - * @returns {Promise} ZIP blob - */ -export async function exportToObsidianZip(books, annotationsByBook) { - const entries = []; - const usedNames = new Set(); - - books.forEach((book) => { - const bookAnns = annotationsByBook[book.ContentID] || []; - if (bookAnns.length === 0) return; - - // Create a clean filename - const safeTitle = (book.Title || 'Untitled') - .replace(/[^a-z0-9]/gi, '_') - .toLowerCase() - .slice(0, 50); - - // De-duplicate: two books with the same/similar title would otherwise - // collide to one .md entry and silently drop notes. - let filename = `${safeTitle}.md`; - let counter = 2; - while (usedNames.has(filename)) { - filename = `${safeTitle}_${counter}.md`; - counter++; - } - usedNames.add(filename); - - const markdownContent = generateObsidianMarkdown(book, bookAnns); - - entries.push({ - name: filename, - input: markdownContent - }); - }); - - if (entries.length === 0) { - throw new Error('No annotations found to export.'); - } - - return await downloadZip(entries).blob(); -} - -/** - * Clean string for CSV escaping. - * Also neutralizes CSV/formula injection: a field starting with = + - @ (or a - * control char) can execute as a formula if the CSV is opened in a spreadsheet. - * Prefixing with a single quote forces it to be treated as plain text. - */ -function escapeCsvField(str = '') { - let value = String(str); - if (/^[=+\-@\t\r]/.test(value)) { - value = `'${value}`; - } - return `"${value.replace(/"/g, '""')}"`; -} - -/** - * Export annotations as Anki-compatible CSV - * @param {Array} annotations - Array of annotations - * @returns {Blob} CSV blob - */ -export function exportToAnkiCsv(annotations) { - // CSV header: Front, Back, Tags - let csvContent = 'Front,Back,Tags\n'; - - annotations.forEach((ann) => { - const title = ann.BookTitle || 'Unknown'; - const author = ann.Author || 'Unknown'; - - // Anki card design: Front shows blockquote of text and the source - const frontText = `
${ann.HighlightedText || ''}

— ${title} by ${author}`; - - // Back shows note or a default string - const backText = ann.Note || 'No custom note'; - - // Create tag based on book title - const tag = `kobo_notes ${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}`; - - csvContent += `${escapeCsvField(frontText)},${escapeCsvField(backText)},${escapeCsvField(tag)}\n`; - }); - - return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); -} diff --git a/src/utils/export.test.js b/src/utils/export.test.ts similarity index 58% rename from src/utils/export.test.js rename to src/utils/export.test.ts index 57ccf4a..c3f9de6 100644 --- a/src/utils/export.test.js +++ b/src/utils/export.test.ts @@ -1,8 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { generateObsidianMarkdown, exportToAnkiCsv, exportToObsidianZip } from './export.js'; +import { ZipReader, BlobReader } from '@zip.js/zip.js'; +import { generateObsidianMarkdown, exportToAnkiCsv, exportToObsidianZip } from './export.ts'; -describe('export.js - Obsidian Markdown Generator', () => { - it('should correctly format Obsidian Markdown with YAML frontmatter', () => { +describe('export.ts - Obsidian Markdown Generator', () => { + it('formats Obsidian Markdown with YAML frontmatter', () => { const book = { Title: 'Test Book "Special Edition"', Author: 'Author Name', @@ -10,20 +11,18 @@ describe('export.js - Obsidian Markdown Generator', () => { ISBN: '1234567890', Progress: 42, TimeSpentReading: 80, - ContentID: 'book-1' + ContentID: 'book-1', }; - const bookAnns = [ { HighlightedText: 'This is a sample highlight.\nWith a new line.', Note: 'My thoughts on this.', - DateCreated: '2026-05-19T20:20:00.000Z' - } + DateCreated: new Date('2026-05-19T20:20:00.000Z'), + }, ]; const result = generateObsidianMarkdown(book, bookAnns); - // Verify frontmatter exists expect(result).toContain('---'); expect(result).toContain('title: "Test Book \\"Special Edition\\""'); expect(result).toContain('author: "Author Name"'); @@ -32,8 +31,6 @@ describe('export.js - Obsidian Markdown Generator', () => { expect(result).toContain('progress: 42'); expect(result).toContain('time_spent_minutes: 80'); expect(result).toContain('source: Kobo'); - - // Verify content exists expect(result).toContain('# Test Book "Special Edition"'); expect(result).toContain('**Reading Progress:** 42%'); expect(result).toContain('This is a sample highlight.'); @@ -42,86 +39,67 @@ describe('export.js - Obsidian Markdown Generator', () => { }); }); -describe('export.js - Anki CSV Exporter', () => { - it('should format annotations into escaped CSV lines', async () => { - const annotations = [ +describe('export.ts - Anki CSV Exporter', () => { + it('formats annotations into escaped CSV lines', async () => { + const text = await exportToAnkiCsv([ { BookTitle: 'Title A', Author: 'Author A', HighlightedText: 'Highlight A with "quotes"', Note: 'Note A', - BookmarkID: 'ann-1' + BookmarkID: 'ann-1', }, { BookTitle: 'Title B', Author: 'Author B', HighlightedText: 'Highlight B', Note: '', - BookmarkID: 'ann-2' - } - ]; - - const blob = exportToAnkiCsv(annotations); - const text = await blob.text(); + BookmarkID: 'ann-2', + }, + ]).text(); const lines = text.split('\n'); expect(lines[0]).toBe('Front,Back,Tags'); - - // Check second line (first annotation) expect(lines[1]).toContain('Title A'); expect(lines[1]).toContain('Author A'); expect(lines[1]).toContain('Highlight A with ""quotes""'); expect(lines[1]).toContain('Note A'); expect(lines[1]).toContain('kobo_notes title_a'); - - // Check third line (second annotation) expect(lines[2]).toContain('Title B'); expect(lines[2]).toContain('Highlight B'); - expect(lines[2]).toContain('No custom note'); // Default fallback + expect(lines[2]).toContain('No custom note'); expect(lines[2]).toContain('kobo_notes title_b'); }); - it('should neutralize CSV/formula injection in note field', async () => { - const annotations = [ + it('neutralizes CSV/formula injection in note field', async () => { + const text = await exportToAnkiCsv([ { BookTitle: 'Book', Author: 'Auth', HighlightedText: 'safe', Note: '=HYPERLINK("http://evil")', - BookmarkID: 'ann-x' - } - ]; - - const text = await exportToAnkiCsv(annotations).text(); - // The leading '=' must be prefixed with a single quote so spreadsheets - // treat it as text, not a formula. + BookmarkID: 'x', + }, + ]).text(); expect(text).toContain(`"'=HYPERLINK`); expect(text).not.toContain('"=HYPERLINK'); }); }); -describe('export.js - Obsidian ZIP de-duplication', () => { - it('should give colliding titles distinct filenames', async () => { - const books = [ - { ContentID: 'c1', Title: 'My Book' }, - { ContentID: 'c2', Title: 'My Book' }, // same title -> same slug - ]; - const annotationsByBook = { - c1: [{ HighlightedText: 'a' }], - c2: [{ HighlightedText: 'b' }], - }; - - const blob = await exportToObsidianZip(books, annotationsByBook); - // Two annotated books -> two distinct entries, not one overwritten file. - expect(blob.size).toBeGreaterThan(0); - - const { ZipReader, BlobReader, TextWriter } = await import('@zip.js/zip.js'); +describe('export.ts - Obsidian ZIP de-duplication', () => { + it('gives colliding titles distinct filenames', async () => { + const blob = await exportToObsidianZip( + [ + { ContentID: 'c1', Title: 'My Book' }, + { ContentID: 'c2', Title: 'My Book' }, + ], + { c1: [{ HighlightedText: 'a' }], c2: [{ HighlightedText: 'b' }] }, + ); const reader = new ZipReader(new BlobReader(blob)); const names = (await reader.getEntries()).map((e) => e.filename); await reader.close(); expect(names).toHaveLength(2); - expect(new Set(names).size).toBe(2); // no collision expect(names).toContain('my_book.md'); expect(names).toContain('my_book_2.md'); }); diff --git a/src/utils/export.ts b/src/utils/export.ts new file mode 100644 index 0000000..edda583 --- /dev/null +++ b/src/utils/export.ts @@ -0,0 +1,109 @@ +import { downloadZip } from 'client-zip'; +import type { KoboAnnotation, KoboBook } from '../types/kobo.ts'; + +export type AnnotationsByBook = Record; + +type BookLike = Pick & Partial; +type AnnotationLike = Partial; + +const yamlString = (value: string | null | undefined) => + `"${(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + +/** Filesystem-safe lowercase slug. */ +export function slugify(value: string | null | undefined, maxLength = 50): string { + return (value || 'untitled') + .replace(/[^a-z0-9]/gi, '_') + .toLowerCase() + .slice(0, maxLength); +} + +/** Obsidian Markdown (with YAML frontmatter) for one book's annotations. */ +export function generateObsidianMarkdown(book: Partial, bookAnns: AnnotationLike[]): string { + const frontmatter = [ + '---', + `title: ${yamlString(book.Title)}`, + `author: ${yamlString(book.Author)}`, + `publisher: ${yamlString(book.Publisher)}`, + `isbn: ${yamlString(book.ISBN)}`, + `progress: ${book.Progress || 0}`, + `time_spent_minutes: ${book.TimeSpentReading || 0}`, + 'source: Kobo', + `exported_at: ${new Date().toISOString()}`, + '---', + '', + ].join('\n'); + + let content = `# ${book.Title}\n`; + content += `**Author:** ${book.Author || 'Unknown'}\n`; + if (book.Publisher) content += `**Publisher:** ${book.Publisher}\n`; + if (book.ISBN) content += `**ISBN:** ${book.ISBN}\n`; + content += `**Reading Progress:** ${book.Progress || 0}%\n\n`; + content += '## Highlights & Notes\n\n'; + content += annotationsToMarkdown(bookAnns, 'Highlight'); + + return frontmatter + content; +} + +/** Shared Markdown body for a list of annotations. */ +export function annotationsToMarkdown(annotations: AnnotationLike[], heading = 'Highlight'): string { + return annotations + .map((ann, idx) => { + let md = `### ${heading} ${idx + 1}\n`; + if (ann.HighlightedText) md += `> ${ann.HighlightedText.replace(/\n/g, '\n> ')}\n\n`; + if (ann.Note) md += `**Note:** ${ann.Note}\n\n`; + if (ann.DateCreated) md += `*Added on ${new Date(ann.DateCreated).toLocaleString()}*\n\n`; + return `${md}---\n\n`; + }) + .join(''); +} + +/** Export all annotated books to an Obsidian vault ZIP (one .md per book). */ +export async function exportToObsidianZip( + books: BookLike[], + annotationsByBook: Record, +): Promise { + const entries: { name: string; input: string }[] = []; + const usedNames = new Set(); + + for (const book of books) { + const bookAnns = annotationsByBook[book.ContentID] || []; + if (bookAnns.length === 0) continue; + + // De-duplicate: books with the same/similar title would otherwise collide. + const safeTitle = slugify(book.Title); + let filename = `${safeTitle}.md`; + for (let counter = 2; usedNames.has(filename); counter++) filename = `${safeTitle}_${counter}.md`; + usedNames.add(filename); + + entries.push({ name: filename, input: generateObsidianMarkdown(book, bookAnns) }); + } + + if (entries.length === 0) throw new Error('No annotations found to export.'); + return downloadZip(entries).blob(); +} + +/** + * Escape a CSV field and neutralize CSV/formula injection: a field starting + * with = + - @ (or a control char) can execute as a formula in a spreadsheet. + */ +function escapeCsvField(str: unknown = ''): string { + let value = String(str ?? ''); + if (/^[=+\-@\t\r]/.test(value)) value = `'${value}`; + return `"${value.replace(/"/g, '""')}"`; +} + +/** Anki-compatible CSV: Front, Back, Tags. */ +export function exportToAnkiCsv(annotations: AnnotationLike[]): Blob { + let csvContent = 'Front,Back,Tags\n'; + + for (const ann of annotations) { + const title = ann.BookTitle || 'Unknown'; + const author = ann.Author || 'Unknown'; + const frontText = `
${ann.HighlightedText || ''}

— ${title} by ${author}`; + const backText = ann.Note || 'No custom note'; + const tag = `kobo_notes ${slugify(title, 200)}`; + csvContent += `${escapeCsvField(frontText)},${escapeCsvField(backText)},${escapeCsvField(tag)}\n`; + } + + return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); +} diff --git a/src/utils/fileSystem.js b/src/utils/fileSystem.js deleted file mode 100644 index 18aa3f3..0000000 --- a/src/utils/fileSystem.js +++ /dev/null @@ -1,276 +0,0 @@ -/** - * File System Access API utilities for Kobo Backup Manager - * Wraps browser File System Access API with fallbacks - */ - -import { FileSystemError, ERROR_CODES } from './errors.js'; - -/** - * Select a directory using File System Access API - * @param {object} options - Selection options - * @returns {Promise} Directory handle - */ -export async function selectDirectory(options = {}) { - if (!('showDirectoryPicker' in window)) { - throw new FileSystemError( - 'This browser does not support the File System Access API', - ERROR_CODES.FS_NOT_SUPPORTED - ); - } - try { - return await window.showDirectoryPicker({ - mode: 'read', - startIn: 'desktop', - ...options, - }); - } catch (error) { - if (error.name === 'AbortError') { - return null; // User cancelled - } - throw new FileSystemError( - 'Failed to select directory', - ERROR_CODES.FS_PERMISSION_DENIED, - { originalError: error } - ); - } -} - -/** - * Select the Kobo device directory - * @returns {Promise} Kobo directory handle - */ -export async function selectKoboDirectory() { - const dirHandle = await selectDirectory({ - id: 'kobo-device', - }); - return dirHandle; -} - -/** - * Read a file as ArrayBuffer - * @param {FileSystemFileHandle} fileHandle - File handle to read - * @returns {Promise} File contents as ArrayBuffer - */ -export async function readFile(fileHandle) { - try { - const file = await fileHandle.getFile(); - const arrayBuffer = await file.arrayBuffer(); - return arrayBuffer; - } catch (error) { - throw new FileSystemError( - 'Failed to read file', - ERROR_CODES.FS_READ_ERROR, - { filename: fileHandle.name, originalError: error } - ); - } -} - -/** - * Save a file to the user's system - * @param {Blob} blob - Blob to save - * @param {object} options - Save options - * @returns {Promise<{filename: string, size: number}>} Save result - */ -export async function saveFile(blob, options = {}) { - try { - const { fileName = 'download.zip', extensions = ['.zip'] } = options; - - console.log('[FS] Saving file via download link:', { fileName, size: blob.size }); - - // Use direct download link instead of showSaveFilePicker - // This avoids SecurityError when called after async operations - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - a.style.display = 'none'; - - document.body.appendChild(a); - a.click(); - - // Cleanup - setTimeout(() => { - document.body.removeChild(a); - URL.revokeObjectURL(url); - }, 100); - - console.log('[FS] Download triggered successfully'); - return { - filename: fileName, - size: blob.size, - }; - } catch (error) { - console.error('[FS ERROR] saveFile failed:', error); - console.error('[FS ERROR] Error name:', error.name); - console.error('[FS ERROR] Error message:', error.message); - - throw new FileSystemError( - 'Failed to save file', - ERROR_CODES.FS_WRITE_ERROR, - { originalError: error } - ); - } -} - -/** - * Get all files in a directory (recursive) - * @param {FileSystemDirectoryHandle} dirHandle - Directory handle - * @param {string} path - Current path (for recursion) - * @returns {Promise>} Array of file handles with paths - */ -export async function getAllFiles(dirHandle, path = '') { - const files = []; - - try { - for await (const entry of dirHandle.values()) { - const entryPath = path ? `${path}/${entry.name}` : entry.name; - - if (entry.kind === 'file') { - files.push({ - handle: entry, - path: entryPath, - name: entry.name, - }); - } else if (entry.kind === 'directory') { - // Skip hidden directories and system folders - if (!entry.name.startsWith('.') && entry.name !== 'System Volume Information') { - const subFiles = await getAllFiles(entry, entryPath); - files.push(...subFiles); - } - } - } - } catch (error) { - console.error('Error reading directory:', error); - } - - return files; -} - -/** - * Get a specific file from directory by path - * @param {FileSystemDirectoryHandle} dirHandle - Directory handle - * @param {string} path - Path to file (e.g., ".kobo/KoboReader.sqlite") - * @returns {Promise} File handle - */ -export async function getFileByPath(dirHandle, path) { - try { - const parts = path.split('/'); - let current = dirHandle; - - // Navigate through directories - for (let i = 0; i < parts.length - 1; i++) { - current = await current.getDirectoryHandle(parts[i]); - } - - // Get the file - const fileName = parts[parts.length - 1]; - const fileHandle = await current.getFileHandle(fileName); - return fileHandle; - } catch (error) { - throw new FileSystemError( - `File not found: ${path}`, - ERROR_CODES.FS_NOT_FOUND, - { path, originalError: error } - ); - } -} - -/** - * Get a specific directory from directory by path - * @param {FileSystemDirectoryHandle} dirHandle - Directory handle - * @param {string} path - Path to directory (e.g., ".kobo") - * @returns {Promise} Directory handle - */ -export async function getDirectoryByPath(dirHandle, path) { - try { - const parts = path.split('/'); - let current = dirHandle; - - for (const part of parts) { - if (part) { - current = await current.getDirectoryHandle(part); - } - } - - return current; - } catch (error) { - throw new FileSystemError( - `Directory not found: ${path}`, - ERROR_CODES.FS_NOT_FOUND, - { path, originalError: error } - ); - } -} - -/** - * Write data to a file - * @param {FileSystemDirectoryHandle} dirHandle - Parent directory - * @param {string} filename - Name of file to write - * @param {Blob|ArrayBuffer|string} data - Data to write - * @returns {Promise} - */ -export async function writeFile(dirHandle, filename, data) { - try { - const fileHandle = await dirHandle.getFileHandle(filename, { create: true }); - const writable = await fileHandle.createWritable(); - - await writable.write(data); - await writable.close(); - } catch (error) { - throw new FileSystemError( - 'Failed to write file', - ERROR_CODES.FS_WRITE_ERROR, - { filename, originalError: error } - ); - } -} - -/** - * Write data to a file at a specific path, creating directories as needed - * @param {FileSystemDirectoryHandle} rootHandle - Root directory handle - * @param {string} filePath - Path to file (e.g., "Libri/subfolder/book.epub") - * @param {Blob|ArrayBuffer|string} data - Data to write - * @returns {Promise} - */ -export async function writeFileToPath(rootHandle, filePath, data) { - try { - // Normalize path separators and remove leading slash - const normalizedPath = filePath.replace(/\\/g, '/').replace(/^\//, ''); - const parts = normalizedPath.split('/'); - - // SECURITY: reject path traversal. A malicious or corrupt backup ZIP could - // embed entries like "books/../../../etc/passwd". Although the File System - // Access API itself rejects "." / ".." in getDirectoryHandle, we fail fast - // and explicitly here so a crafted path can never escape the device root. - if (parts.some((p) => p === '..' || p === '.')) { - throw new FileSystemError( - `Unsafe path rejected (path traversal): ${filePath}`, - ERROR_CODES.FS_WRITE_ERROR, - { filePath } - ); - } - - const filename = parts.pop(); - - // Navigate/create directories - let currentDir = rootHandle; - for (const dirName of parts) { - if (dirName && dirName !== '.') { - currentDir = await currentDir.getDirectoryHandle(dirName, { create: true }); - } - } - - // Write the file - await writeFile(currentDir, filename, data); - } catch (error) { - // Preserve our own typed errors (e.g. the traversal guard above) - if (error instanceof FileSystemError) { - throw error; - } - throw new FileSystemError( - `Failed to write file at path: ${filePath}`, - ERROR_CODES.FS_WRITE_ERROR, - { filePath, originalError: error } - ); - } -} diff --git a/src/utils/fileSystem.test.js b/src/utils/fileSystem.test.js deleted file mode 100644 index 07d98db..0000000 --- a/src/utils/fileSystem.test.js +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { writeFileToPath } from './fileSystem.js'; - -describe('fileSystem.js - writeFileToPath path traversal guard', () => { - // A dummy root handle that fails the test if the guard ever lets execution - // reach a real filesystem call. - const trap = { - getFileHandle() { throw new Error('guard bypassed: getFileHandle called'); }, - getDirectoryHandle() { throw new Error('guard bypassed: getDirectoryHandle called'); }, - }; - - it('rejects paths containing ".." segments', async () => { - await expect( - writeFileToPath(trap, 'books/../../../etc/passwd', new Blob(['x'])) - ).rejects.toThrow(/path traversal/i); - }); - - it('rejects backslash-encoded traversal', async () => { - await expect( - writeFileToPath(trap, 'books\\..\\..\\evil.txt', new Blob(['x'])) - ).rejects.toThrow(/path traversal/i); - }); -}); diff --git a/src/utils/fileSystem.test.ts b/src/utils/fileSystem.test.ts new file mode 100644 index 0000000..1deb07e --- /dev/null +++ b/src/utils/fileSystem.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { writeFileToPath } from './fileSystem.ts'; + +describe('fileSystem.ts - writeFileToPath path traversal guard', () => { + // A dummy root handle that fails the test if the guard ever lets execution + // reach a real filesystem call. + const trap = { + getFileHandle() { + throw new Error('guard bypassed: getFileHandle called'); + }, + getDirectoryHandle() { + throw new Error('guard bypassed: getDirectoryHandle called'); + }, + } as unknown as FileSystemDirectoryHandle; + + it('rejects paths containing ".." segments', async () => { + await expect(writeFileToPath(trap, 'books/../../../etc/passwd', new Blob(['x']))).rejects.toThrow( + /path traversal/i, + ); + }); + + it('rejects backslash-encoded traversal', async () => { + await expect(writeFileToPath(trap, 'books\\..\\..\\evil.txt', new Blob(['x']))).rejects.toThrow( + /path traversal/i, + ); + }); +}); diff --git a/src/utils/fileSystem.ts b/src/utils/fileSystem.ts new file mode 100644 index 0000000..c1575e5 --- /dev/null +++ b/src/utils/fileSystem.ts @@ -0,0 +1,188 @@ +/** + * File System Access API utilities. + */ + +import { FileSystemError, ERROR_CODES } from './errors.ts'; + +export interface DeviceFile { + handle: FileSystemFileHandle; + path: string; + name: string; +} + +/** Folders never descended into while scanning (hidden/system). */ +function isSkippedDirectory(name: string): boolean { + return name.startsWith('.') || name === 'System Volume Information'; +} + +/** + * Let the user pick a directory. Returns null if the dialog is dismissed. + */ +export async function selectDirectory( + options: DirectoryPickerOptions = {}, +): Promise { + if (!('showDirectoryPicker' in window)) { + throw new FileSystemError( + 'This browser does not support the File System Access API', + ERROR_CODES.FS_NOT_SUPPORTED, + ); + } + try { + return await window.showDirectoryPicker({ mode: 'read', startIn: 'desktop', ...options }); + } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return null; + throw new FileSystemError('Failed to select directory', ERROR_CODES.FS_PERMISSION_DENIED, { + originalError: error, + }); + } +} + +export function selectKoboDirectory(): Promise { + return selectDirectory({ id: 'kobo-device' }); +} + +/** Read a file handle fully into memory. */ +export async function readFile(fileHandle: FileSystemFileHandle): Promise { + try { + return await (await fileHandle.getFile()).arrayBuffer(); + } catch (error) { + throw new FileSystemError('Failed to read file', ERROR_CODES.FS_READ_ERROR, { + filename: fileHandle.name, + originalError: error, + }); + } +} + +/** Trigger a browser download for an in-memory blob. */ +export function downloadBlob(blob: Blob, fileName: string): { filename: string; size: number } { + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + a.remove(); + URL.revokeObjectURL(url); + }, 100); + return { filename: fileName, size: blob.size }; +} + +/** Recursively list files, skipping hidden and system folders. */ +export async function getAllFiles(dirHandle: FileSystemDirectoryHandle, path = ''): Promise { + const files: DeviceFile[] = []; + + try { + for await (const entry of dirHandle.values()) { + const entryPath = path ? `${path}/${entry.name}` : entry.name; + + if (entry.kind === 'file') { + files.push({ handle: entry, path: entryPath, name: entry.name }); + } else if (!isSkippedDirectory(entry.name)) { + files.push(...(await getAllFiles(entry, entryPath))); + } + } + } catch (error) { + console.error('Error reading directory:', path || '/', error); + } + + return files; +} + +function splitPath(path: string): string[] { + return path.replace(/\\/g, '/').split('/').filter(Boolean); +} + +/** Get a file handle by relative path (e.g. ".kobo/KoboReader.sqlite"). */ +export async function getFileByPath( + dirHandle: FileSystemDirectoryHandle, + path: string, +): Promise { + try { + const parts = splitPath(path); + const fileName = parts.pop()!; + let current = dirHandle; + for (const part of parts) current = await current.getDirectoryHandle(part); + return await current.getFileHandle(fileName); + } catch (error) { + throw new FileSystemError(`File not found: ${path}`, ERROR_CODES.FS_NOT_FOUND, { + path, + originalError: error, + }); + } +} + +/** Get a directory handle by relative path (e.g. ".kobo"). */ +export async function getDirectoryByPath( + dirHandle: FileSystemDirectoryHandle, + path: string, +): Promise { + try { + let current = dirHandle; + for (const part of splitPath(path)) current = await current.getDirectoryHandle(part); + return current; + } catch (error) { + throw new FileSystemError(`Directory not found: ${path}`, ERROR_CODES.FS_NOT_FOUND, { + path, + originalError: error, + }); + } +} + +/** Write data to a file inside a directory (creating/overwriting it). */ +export async function writeFile( + dirHandle: FileSystemDirectoryHandle, + filename: string, + data: FileSystemWriteChunkType, +): Promise { + try { + const fileHandle = await dirHandle.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + await writable.write(data); + await writable.close(); + } catch (error) { + throw new FileSystemError('Failed to write file', ERROR_CODES.FS_WRITE_ERROR, { + filename, + originalError: error, + }); + } +} + +/** + * Write data at a relative path, creating intermediate directories. + * Rejects path traversal so a crafted backup can never escape the device root. + */ +export async function writeFileToPath( + rootHandle: FileSystemDirectoryHandle, + filePath: string, + data: FileSystemWriteChunkType, +): Promise { + const parts = splitPath(filePath); + + // SECURITY: a malicious or corrupt backup ZIP could embed entries like + // "books/../../../etc/passwd". The File System Access API also rejects + // "." / "..", but we fail fast and explicitly here. + if (parts.some((p) => p === '..' || p === '.')) { + throw new FileSystemError( + `Unsafe path rejected (path traversal): ${filePath}`, + ERROR_CODES.FS_WRITE_ERROR, + { + filePath, + }, + ); + } + + try { + const filename = parts.pop()!; + let currentDir = rootHandle; + for (const dirName of parts) currentDir = await currentDir.getDirectoryHandle(dirName, { create: true }); + await writeFile(currentDir, filename, data); + } catch (error) { + if (error instanceof FileSystemError) throw error; + throw new FileSystemError(`Failed to write file at path: ${filePath}`, ERROR_CODES.FS_WRITE_ERROR, { + filePath, + originalError: error, + }); + } +} diff --git a/src/utils/formatters.js b/src/utils/formatters.js deleted file mode 100644 index 30056a6..0000000 --- a/src/utils/formatters.js +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Utility functions for formatting data - */ - -/** - * Format bytes to human-readable size - * @param {number} bytes - Number of bytes - * @param {number} decimals - Number of decimal places (default: 2) - * @returns {string} Formatted size string (e.g., "1.5 GB") - */ -export function formatBytes(bytes, decimals = 2) { - if (bytes === 0) return '0 Bytes'; - if (!bytes) return 'Unknown'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - - const i = Math.floor(Math.log(bytes) / Math.log(k)); - - return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; -} - -/** - * Format date to human-readable string - * @param {string|Date} date - Date to format - * @param {object} options - Intl.DateTimeFormat options - * @returns {string} Formatted date string - */ -export function formatDate(date, options = {}) { - if (!date) return 'Never'; - - const dateObj = date instanceof Date ? date : new Date(date); - - if (isNaN(dateObj.getTime())) return 'Invalid date'; - - const defaultOptions = { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }; - - return new Intl.DateTimeFormat('en-US', { ...defaultOptions, ...options }).format(dateObj); -} - -/** - * Format date to relative time (e.g., "2 hours ago") - * @param {string|Date} date - Date to format - * @returns {string} Relative time string - */ -export function formatRelativeTime(date) { - if (!date) return 'Never'; - - const dateObj = date instanceof Date ? date : new Date(date); - const now = new Date(); - const diffMs = now - dateObj; - const diffSec = Math.floor(diffMs / 1000); - const diffMin = Math.floor(diffSec / 60); - const diffHour = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHour / 24); - - if (diffSec < 60) return 'Just now'; - if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`; - if (diffHour < 24) return `${diffHour} hour${diffHour > 1 ? 's' : ''} ago`; - if (diffDay < 7) return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`; - if (diffDay < 30) return `${Math.floor(diffDay / 7)} week${Math.floor(diffDay / 7) > 1 ? 's' : ''} ago`; - if (diffDay < 365) return `${Math.floor(diffDay / 30)} month${Math.floor(diffDay / 30) > 1 ? 's' : ''} ago`; - return `${Math.floor(diffDay / 365)} year${Math.floor(diffDay / 365) > 1 ? 's' : ''} ago`; -} - -/** - * Format progress percentage - * @param {number} progress - Progress value (0-100) - * @returns {string} Formatted percentage string - */ -export function formatProgress(progress) { - if (progress === null || progress === undefined) return '0%'; - - const value = Math.min(100, Math.max(0, progress)); - return `${Math.round(value)}%`; -} - -/** - * Format duration in minutes to readable string - * @param {number} minutes - Duration in minutes - * @returns {string} Formatted duration (e.g., "2h 30m") - */ -export function formatDuration(minutes) { - if (!minutes || minutes === 0) return '0m'; - - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - - if (hours === 0) return `${mins}m`; - if (mins === 0) return `${hours}h`; - return `${hours}h ${mins}m`; -} - -/** - * Truncate string to maximum length with ellipsis - * @param {string} str - String to truncate - * @param {number} maxLength - Maximum length - * @returns {string} Truncated string - */ -export function truncate(str, maxLength = 50) { - if (!str) return ''; - if (str.length <= maxLength) return str; - return str.substring(0, maxLength - 3) + '...'; -} - -/** - * Format file path for display (show filename only) - * @param {string} path - Full file path - * @returns {string} Filename only - */ -export function formatFilename(path) { - if (!path) return ''; - return path.split('/').pop().split('\\').pop(); -} - -/** - * Format content ID to readable filename - * @param {string} contentId - Kobo content ID - * @returns {string} Formatted filename - */ -export function formatContentId(contentId) { - if (!contentId) return ''; - // Remove file:// prefix and path - return contentId.replace(/^file:\/\//, '').split('/').pop(); -} diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 0000000..9da75c7 --- /dev/null +++ b/src/utils/formatters.ts @@ -0,0 +1,55 @@ +/** + * Utility functions for formatting data + */ + +/** Format bytes to a human-readable size (e.g. "1.5 GB"). */ +export function formatBytes(bytes: number | null | undefined, decimals = 2): string { + if (bytes === 0) return '0 Bytes'; + if (!bytes) return 'Unknown'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; +} + +/** Format a date to a human-readable string. */ +export function formatDate( + date: string | Date | null | undefined, + options: Intl.DateTimeFormatOptions = {}, + locale = 'en-US', +): string { + if (!date) return 'Never'; + + const dateObj = date instanceof Date ? date : new Date(date); + if (isNaN(dateObj.getTime())) return 'Invalid date'; + + return new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + ...options, + }).format(dateObj); +} + +/** Format a progress percentage. */ +export function formatProgress(progress: number | null | undefined): string { + if (progress === null || progress === undefined) return '0%'; + return `${Math.round(Math.min(100, Math.max(0, progress)))}%`; +} + +/** Format a duration in minutes (e.g. "2h 30m"). */ +export function formatDuration(minutes: number | null | undefined): string { + if (!minutes) return '0m'; + + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + + if (hours === 0) return `${mins}m`; + if (mins === 0) return `${hours}h`; + return `${hours}h ${mins}m`; +} diff --git a/src/utils/koboCovers.js b/src/utils/koboCovers.js deleted file mode 100644 index fb80c30..0000000 --- a/src/utils/koboCovers.js +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Helper to fetch cover images from connected Kobo device (.kobo/images folder) - */ -import { getDirectoryByPath } from './fileSystem.js'; - -/** - * Common suffixes used by Kobo firmware for pre-rendered book cover JPEGs - */ -const COB_COVER_SUFFIXES = [ - ' - N3_LIBRARY_GRID.parsed', - ' - N3_LIBRARY_SHELF.parsed', - ' - N3_LIBRARY_LIST.parsed', - ' - N3_LIBRARY_FULL.parsed', - ' - NickelBookCover.parsed' -]; - -/** - * Retrieve the cover file from Kobo device - * @param {FileSystemDirectoryHandle} deviceHandle - Connected Kobo device directory handle - * @param {string} coverId - ImageId / CoverId from SQLite database - * @returns {Promise} Image file or null if not found - */ -export async function getCoverFile(deviceHandle, coverId) { - if (!deviceHandle || !coverId) { - return null; - } - - try { - const imagesDir = await getDirectoryByPath(deviceHandle, '.kobo/images'); - - for (const suffix of COB_COVER_SUFFIXES) { - try { - const filename = `${coverId}${suffix}`; - const fileHandle = await imagesDir.getFileHandle(filename); - const file = await fileHandle.getFile(); - return file; - } catch (err) { - // Try next suffix on NotFoundError - } - } - } catch (error) { - // .kobo/images might not exist or be accessible - console.debug('[Covers] Could not access .kobo/images directory:', error.message); - } - - return null; -} - -/** - * Create a local Object URL for the cover image - * @param {FileSystemDirectoryHandle} deviceHandle - Connected Kobo device directory handle - * @param {string} coverId - ImageId / CoverId from SQLite database - * @returns {Promise} Object URL string or null if not found (caller is responsible for revoking) - */ -export async function getCoverUrl(deviceHandle, coverId) { - const file = await getCoverFile(deviceHandle, coverId); - if (file) { - try { - return URL.createObjectURL(file); - } catch (e) { - console.error('[Covers] Failed to create Object URL:', e); - } - } - return null; -} diff --git a/src/utils/koboCovers.test.js b/src/utils/koboCovers.test.js deleted file mode 100644 index c84e25b..0000000 --- a/src/utils/koboCovers.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { getCoverFile, getCoverUrl } from './koboCovers.js'; -import { getDirectoryByPath } from './fileSystem.js'; - -// Mock getDirectoryByPath from fileSystem.js -vi.mock('./fileSystem.js', () => ({ - getDirectoryByPath: vi.fn() -})); - -describe('koboCovers.js - Cover Extractor', () => { - it('should return null if deviceHandle or coverId is missing', async () => { - expect(await getCoverFile(null, 'test-id')).toBeNull(); - expect(await getCoverFile({}, null)).toBeNull(); - }); - - it('should look up cover with suffixes and return the mock file if found', async () => { - const mockFile = new File(['mock-content'], 'test-cover-N3_LIBRARY_GRID.parsed', { type: 'image/jpeg' }); - - // Create mock FileHandle - const mockFileHandle = { - getFile: vi.fn().mockResolvedValue(mockFile) - }; - - // Create mock DirectoryHandle for .kobo/images - const mockImagesDir = { - getFileHandle: vi.fn().mockImplementation((filename) => { - if (filename === 'my-cover-id - N3_LIBRARY_GRID.parsed') { - return Promise.resolve(mockFileHandle); - } - return Promise.reject(new Error('File not found')); - }) - }; - - // Make getDirectoryByPath return our mockImagesDir - vi.mocked(getDirectoryByPath).mockResolvedValue(mockImagesDir); - - const mockDeviceHandle = {}; - const resultFile = await getCoverFile(mockDeviceHandle, 'my-cover-id'); - - expect(getDirectoryByPath).toHaveBeenCalledWith(mockDeviceHandle, '.kobo/images'); - expect(mockImagesDir.getFileHandle).toHaveBeenCalledWith('my-cover-id - N3_LIBRARY_GRID.parsed'); - expect(resultFile).toBe(mockFile); - }); - - it('should return null if the cover file does not exist under any suffix', async () => { - const mockImagesDir = { - getFileHandle: vi.fn().mockRejectedValue(new Error('File not found')) - }; - - vi.mocked(getDirectoryByPath).mockResolvedValue(mockImagesDir); - - const mockDeviceHandle = {}; - const resultFile = await getCoverFile(mockDeviceHandle, 'invalid-cover-id'); - - expect(resultFile).toBeNull(); - expect(mockImagesDir.getFileHandle).toHaveBeenCalledTimes(5); // Checked all 5 suffixes - }); -}); diff --git a/src/utils/koboCovers.test.ts b/src/utils/koboCovers.test.ts new file mode 100644 index 0000000..0129ca9 --- /dev/null +++ b/src/utils/koboCovers.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getCoverFile } from './koboCovers.ts'; +import { getDirectoryByPath } from './fileSystem.ts'; + +vi.mock('./fileSystem.ts', () => ({ getDirectoryByPath: vi.fn() })); + +const asDir = (value: unknown) => value as FileSystemDirectoryHandle; + +describe('koboCovers.ts - Cover Extractor', () => { + it('returns null if deviceHandle or coverId is missing', async () => { + expect(await getCoverFile(null, 'test-id')).toBeNull(); + expect(await getCoverFile(asDir({}), null)).toBeNull(); + }); + + it('looks up the cover with suffixes and returns the file if found', async () => { + const mockFile = new File(['mock-content'], 'cover.parsed', { type: 'image/jpeg' }); + const mockImagesDir = { + getFileHandle: vi.fn((filename: string) => + filename === 'my-cover-id - N3_LIBRARY_GRID.parsed' + ? Promise.resolve({ getFile: () => Promise.resolve(mockFile) }) + : Promise.reject(new Error('File not found')), + ), + }; + vi.mocked(getDirectoryByPath).mockResolvedValue(asDir(mockImagesDir)); + + const device = asDir({}); + const resultFile = await getCoverFile(device, 'my-cover-id'); + + expect(getDirectoryByPath).toHaveBeenCalledWith(device, '.kobo/images'); + expect(mockImagesDir.getFileHandle).toHaveBeenCalledWith('my-cover-id - N3_LIBRARY_GRID.parsed'); + expect(resultFile).toBe(mockFile); + }); + + it('returns null if the cover file does not exist under any suffix', async () => { + const mockImagesDir = { getFileHandle: vi.fn().mockRejectedValue(new Error('File not found')) }; + vi.mocked(getDirectoryByPath).mockResolvedValue(asDir(mockImagesDir)); + + expect(await getCoverFile(asDir({}), 'invalid-cover-id')).toBeNull(); + expect(mockImagesDir.getFileHandle).toHaveBeenCalledTimes(5); + }); +}); diff --git a/src/utils/koboCovers.ts b/src/utils/koboCovers.ts new file mode 100644 index 0000000..ed1c79b --- /dev/null +++ b/src/utils/koboCovers.ts @@ -0,0 +1,44 @@ +/** + * Fetch cover images from a connected Kobo device. + */ +import { getDirectoryByPath } from './fileSystem.ts'; + +/** Suffixes used by Kobo firmware for pre-rendered cover JPEGs. */ +const COVER_SUFFIXES = [ + ' - N3_LIBRARY_GRID.parsed', + ' - N3_LIBRARY_SHELF.parsed', + ' - N3_LIBRARY_LIST.parsed', + ' - N3_LIBRARY_FULL.parsed', + ' - NickelBookCover.parsed', +]; + +/** Retrieve the cover file for an ImageId, or null if not found. */ +export async function getCoverFile( + deviceHandle: FileSystemDirectoryHandle | null, + coverId: string | null, +): Promise { + if (!deviceHandle || !coverId) return null; + + try { + const imagesDir = await getDirectoryByPath(deviceHandle, '.kobo/images'); + for (const suffix of COVER_SUFFIXES) { + try { + return await (await imagesDir.getFileHandle(`${coverId}${suffix}`)).getFile(); + } catch { + // Try next suffix + } + } + } catch (error) { + console.debug('[Covers] Could not access .kobo/images directory:', error); + } + return null; +} + +/** Object URL for the cover image (caller must revoke it). */ +export async function getCoverUrl( + deviceHandle: FileSystemDirectoryHandle | null, + coverId: string | null, +): Promise { + const file = await getCoverFile(deviceHandle, coverId); + return file ? URL.createObjectURL(file) : null; +} diff --git a/src/utils/koboDatabase.js b/src/utils/koboDatabase.js deleted file mode 100644 index 43a67cb..0000000 --- a/src/utils/koboDatabase.js +++ /dev/null @@ -1,436 +0,0 @@ -/** - * Kobo Database Parser using sql.js - * Reads KoboReader.sqlite and extracts books, annotations, and reading progress - */ - -import { DatabaseError, ERROR_CODES } from './errors.js'; - -/** - * Kobo Database wrapper class - */ -export class KoboDatabase { - /** - * Create a new KoboDatabase instance - * @param {ArrayBuffer} arrayBuffer - SQLite database file as ArrayBuffer - */ - constructor(arrayBuffer) { - this.arrayBuffer = arrayBuffer; - this.db = null; - this.ready = this.initialize(); - } - - /** - * Initialize sql.js and open the database - * @private - */ - async initialize() { - try { - // Dynamically import sql.js - const initSqlJs = (await import('sql.js')).default; - - const SQL = await initSqlJs({ - // Load sql-wasm.wasm from local public directory - locateFile: file => `/${file}` - }); - - this.db = new SQL.Database(new Uint8Array(this.arrayBuffer)); - } catch (error) { - throw new DatabaseError( - 'Failed to initialize database', - ERROR_CODES.DB_OPEN_FAILED, - { originalError: error } - ); - } - } - - /** - * Parse SQL results to JavaScript array of objects - * @private - * @param {Array} results - SQL results from exec() - * @returns {Array} Array of row objects - */ - parseResults(results) { - if (!results || results.length === 0) { - return []; - } - - const result = results[0]; - const { columns, values } = result; - - return values.map(row => { - const obj = {}; - columns.forEach((col, i) => { - obj[col] = row[i]; - }); - return obj; - }); - } - - /** - * Get all books from the database - * Fetches sideloaded books (not from Kobo store) - * @returns {Promise>} Array of book objects - */ - async getBooks() { - await this.ready; - - try { - - // Use ContentID as FilePath - it contains the full file:// path - const query = ` - SELECT - ContentID, - Title, - Attribution as Author, - Description, - Publisher, - Series, - SeriesNumber, - ISBN, - Language, - ___PercentRead as Progress, - ReadStatus, - DateCreated, - DateLastRead, - ContentID as FilePath, - ImageId as CoverId, - TimeSpentReading, - MimeType - FROM content - WHERE ContentType = 6 - AND LOWER(IsDownloaded) = 'true' - AND (BookTitle IS NULL OR BookTitle = '') - AND ContentID LIKE 'file://%' - ORDER BY DateLastRead DESC - `; - - const result = this.db.exec(query); - const books = this.parseResults(result); - - // Parse and clean the data - return books.map(book => ({ - ...book, - Progress: book.Progress ? Math.round(book.Progress) : 0, - Author: book.Author || 'Unknown Author', - DateLastRead: book.DateLastRead ? new Date(book.DateLastRead) : null, - DateCreated: book.DateCreated ? new Date(book.DateCreated) : null, - TimeSpentReading: book.TimeSpentReading || 0, - FilePath: book.FilePath ? decodeURIComponent(book.FilePath.replace('file://', '')) : null, - })); - } catch (error) { - console.error('[DB ERROR] getBooks failed:', error.message); - throw new DatabaseError( - 'Failed to fetch books from database', - ERROR_CODES.DB_QUERY_FAILED, - { originalError: error } - ); - } - } - - /** - * Get all annotations (highlights and notes) from the database - * @returns {Promise>} Array of annotation objects - */ - async getAnnotations() { - await this.ready; - - try { - // Remove BookmarkType - column doesn't exist in this schema - const query = ` - SELECT - b.BookmarkID, - b.VolumeID, - b.Text as HighlightedText, - b.Annotation as Note, - b.DateCreated, - b.DateModified, - b.StartContainerPath, - b.StartOffset, - b.EndContainerPath, - b.EndOffset, - c.Title as BookTitle, - c.Attribution as Author - FROM Bookmark b - LEFT JOIN content c ON b.VolumeID = c.ContentID - WHERE (b.Text IS NOT NULL OR b.Annotation IS NOT NULL) - AND c.ContentType = 6 - ORDER BY b.DateCreated DESC - `; - - const result = this.db.exec(query); - const annotations = this.parseResults(result); - - // Parse and clean the data - return annotations.map(annotation => ({ - ...annotation, - DateCreated: annotation.DateCreated ? new Date(annotation.DateCreated) : null, - DateModified: annotation.DateModified ? new Date(annotation.DateModified) : null, - BookTitle: annotation.BookTitle || 'Unknown Book', - Author: annotation.Author || 'Unknown Author', - })); - } catch (error) { - console.error('[DB ERROR] getAnnotations failed:', error); - console.error('[DB ERROR] Error message:', error.message); - throw new DatabaseError( - 'Failed to fetch annotations from database', - ERROR_CODES.DB_QUERY_FAILED, - { originalError: error } - ); - } - } - - /** - * Get reading statistics - * @returns {Promise} Reading statistics - */ - async getReadingStats() { - await this.ready; - - try { - const query = ` - SELECT - COUNT(*) as TotalBooks, - SUM(CASE WHEN ___PercentRead > 0 THEN 1 ELSE 0 END) as BooksStarted, - SUM(CASE WHEN ___PercentRead >= 100 THEN 1 ELSE 0 END) as BooksFinished, - SUM(CASE WHEN ReadStatus = 1 THEN 1 ELSE 0 END) as CurrentlyReading, - SUM(TimeSpentReading) as TotalMinutesRead, - AVG(___PercentRead) as AverageProgress, - COUNT(DISTINCT Attribution) as UniqueAuthors - FROM content - WHERE ContentType = 6 - AND BookTitle IS NULL - AND IsDownloaded = 'true' - AND ContentID LIKE 'file://%' - `; - - const result = this.db.exec(query); - const stats = this.parseResults(result)[0] || {}; - - return { - totalBooks: stats.TotalBooks || 0, - booksStarted: stats.BooksStarted || 0, - booksFinished: stats.BooksFinished || 0, - currentlyReading: stats.CurrentlyReading || 0, - totalMinutesRead: stats.TotalMinutesRead || 0, - averageProgress: stats.AverageProgress ? Math.round(stats.AverageProgress) : 0, - uniqueAuthors: stats.UniqueAuthors || 0, - }; - } catch (error) { - console.error('[DB ERROR] getReadingStats failed:', error); - console.error('[DB ERROR] Error message:', error.message); - throw new DatabaseError( - 'Failed to fetch reading statistics', - ERROR_CODES.DB_QUERY_FAILED, - { originalError: error } - ); - } - } - - /** - * Get device information from database - * @returns {Promise} Device information - */ - async getDeviceInfo() { - await this.ready; - - try { - // Try to get device info from various tables - let deviceModel = 'Unknown Kobo Device'; - let firmwareVersion = 'Unknown'; - - // Check if there's a device info table - const tablesQuery = ` - SELECT name FROM sqlite_master - WHERE type='table' AND name LIKE '%device%' - `; - const tablesResult = this.db.exec(tablesQuery); - - // Get version from user table if available - try { - const versionQuery = `SELECT UserID, StoreUserID FROM user LIMIT 1`; - const versionResult = this.db.exec(versionQuery); - const userData = this.parseResults(versionResult)[0]; - - if (userData) { - // Extract some info (but anonymize sensitive data) - deviceModel = 'Kobo Device'; - } - } catch (e) { - // Table might not exist, that's okay - } - - return { - model: deviceModel, - firmwareVersion: firmwareVersion, - databaseVersion: this.getDatabaseVersion(), - schemaVersion: this.getSchemaVersion(), - }; - } catch (error) { - // If we can't get device info, return defaults - return { - model: 'Unknown Kobo Device', - firmwareVersion: 'Unknown', - databaseVersion: 'Unknown', - schemaVersion: 0, - }; - } - } - - /** - * Get SQLite library version - * @private - * @returns {string} SQLite version string - */ - getDatabaseVersion() { - try { - const query = `SELECT sqlite_version() as version`; - const result = this.db.exec(query); - const data = this.parseResults(result)[0]; - return data ? data.version : 'Unknown'; - } catch (error) { - return 'Unknown'; - } - } - - /** - * Get Kobo database schema version via PRAGMA user_version - * Different Kobo firmware versions bump this number when changing the schema - * @returns {number} Schema version (0 if unknown) - */ - getSchemaVersion() { - try { - const result = this.db.exec('PRAGMA user_version'); - const data = this.parseResults(result)[0]; - return data ? data.user_version : 0; - } catch (error) { - return 0; - } - } - - /** - * Get collections (shelves) - * @returns {Promise>} Array of collection objects - */ - async getCollections() { - await this.ready; - - try { - const query = ` - SELECT - Id, - Name, - InternalName, - Type, - CreationDate, - LastModified - FROM Shelf - WHERE Type != 'SystemTag' - ORDER BY Name - `; - - const result = this.db.exec(query); - const collections = this.parseResults(result); - - return collections.map(collection => ({ - ...collection, - CreationDate: collection.CreationDate ? new Date(collection.CreationDate) : null, - LastModified: collection.LastModified ? new Date(collection.LastModified) : null, - })); - } catch (error) { - // Collections might not exist - return []; - } - } - - /** - * Close the database connection - */ - close() { - if (this.db) { - this.db.close(); - this.db = null; - } - } - - /** - * Export database to ArrayBuffer - * @returns {Promise} Database as ArrayBuffer - */ - async export() { - await this.ready; - const data = this.db.export(); - return data.buffer; - } - - /** - * Sanitize database for restore - * Runs VACUUM and sets incompatible flags to defaults - * @returns {Promise} - */ - async sanitize() { - await this.ready; - try { - console.log('[DB] Running integrity check...'); - const integrity = this.db.exec("PRAGMA integrity_check"); - console.log('[DB] Integrity check result:', integrity[0].values[0][0]); - - console.log('[DB] Sanitizing database...'); - // 1. Force rollback journal mode (no WAL) - this.db.exec("PRAGMA journaling_mode = DELETE"); - - // 2. Vacuum to rebuild the database file structure cleanly - // This fixes fragmentation and ensures a clean SQLite file - this.db.exec("VACUUM"); - - console.log('[DB] Database sanitized successfully'); - } catch (error) { - console.error('[DB ERROR] Sanitize failed:', error); - throw new DatabaseError( - 'Failed to sanitize database', - ERROR_CODES.DB_WRITE_ERROR, - { originalError: error } - ); - } - } -} - -/** - * Open a Kobo database from ArrayBuffer - * @param {ArrayBuffer} arrayBuffer - Database file as ArrayBuffer - * @returns {Promise} KoboDatabase instance - */ -export async function openDatabase(arrayBuffer) { - const db = new KoboDatabase(arrayBuffer); - await db.ready; - return db; -} - -/** - * Extract all data from Kobo database - * @param {ArrayBuffer} arrayBuffer - Database file as ArrayBuffer - * @returns {Promise} All extracted data - */ -export async function extractAllData(arrayBuffer) { - const db = await openDatabase(arrayBuffer); - - try { - const [books, annotations, stats, deviceInfo, collections] = await Promise.all([ - db.getBooks(), - db.getAnnotations(), - db.getReadingStats(), - db.getDeviceInfo(), - db.getCollections(), - ]); - - return { - books, - annotations, - stats, - deviceInfo, - collections, - databaseSize: arrayBuffer.byteLength, - }; - } finally { - db.close(); - } -} diff --git a/src/utils/koboDatabase.ts b/src/utils/koboDatabase.ts new file mode 100644 index 0000000..16dacf9 --- /dev/null +++ b/src/utils/koboDatabase.ts @@ -0,0 +1,287 @@ +/** + * Kobo database parser using sql.js (SQLite compiled to WebAssembly). + * Reads KoboReader.sqlite and extracts books, annotations and reading progress. + */ + +import type { Database, QueryExecResult, SqlJsStatic, SqlValue } from 'sql.js'; +import sqlWasmUrl from 'sql.js/dist/sql-wasm-browser.wasm?url'; +import { DatabaseError, ERROR_CODES } from './errors.ts'; +import type { + DeviceInfo, + ExtractedData, + KoboAnnotation, + KoboBook, + KoboCollection, + ReadingStats, +} from '../types/kobo.ts'; + +type Row = Record; + +let sqlJsPromise: Promise | null = null; + +/** Load sql.js once per page; the WASM binary is bundled and hashed by Vite. */ +function loadSqlJs(): Promise { + sqlJsPromise ??= import('sql.js') + .then(({ default: initSqlJs }) => initSqlJs({ locateFile: () => sqlWasmUrl })) + .catch((error: unknown) => { + sqlJsPromise = null; + throw error; + }); + return sqlJsPromise; +} + +function toDate(value: SqlValue): Date | null { + if (value === null || value === undefined || value === '') return null; + const date = new Date(value as string | number); + return isNaN(date.getTime()) ? null : date; +} + +const str = (value: SqlValue): string | null => + value === null || value === undefined ? null : String(value); +const num = (value: SqlValue): number => (typeof value === 'number' ? value : Number(value) || 0); + +export class KoboDatabase { + private readonly db: Database; + + private constructor(db: Database) { + this.db = db; + } + + static async open(data: ArrayBuffer | Uint8Array): Promise { + try { + const SQL = await loadSqlJs(); + return new KoboDatabase(new SQL.Database(data instanceof Uint8Array ? data : new Uint8Array(data))); + } catch (error) { + throw new DatabaseError('Failed to initialize database', ERROR_CODES.DB_OPEN_FAILED, { + originalError: error, + }); + } + } + + private rows(sql: string, params?: SqlValue[]): Row[] { + const results: QueryExecResult[] = this.db.exec(sql, params); + const first = results[0]; + if (!first) return []; + return first.values.map((values) => Object.fromEntries(first.columns.map((col, i) => [col, values[i]]))); + } + + private query(label: string, fn: () => T): T { + try { + return fn(); + } catch (error) { + console.error(`[DB ERROR] ${label} failed:`, error); + throw new DatabaseError(`Failed to fetch ${label} from database`, ERROR_CODES.DB_QUERY_FAILED, { + originalError: error, + }); + } + } + + /** Sideloaded books (ContentID file://...), most recently read first. */ + getBooks(): KoboBook[] { + return this.query('books', () => + this.rows( + ` + SELECT + ContentID, Title, Attribution AS Author, Description, Publisher, Series, SeriesNumber, + ISBN, Language, ___PercentRead AS Progress, ReadStatus, DateCreated, DateLastRead, + ContentID AS FilePath, ImageId AS CoverId, TimeSpentReading, MimeType + FROM content + WHERE ContentType = 6 + AND LOWER(IsDownloaded) = 'true' + AND (BookTitle IS NULL OR BookTitle = '') + AND ContentID LIKE 'file://%' + ORDER BY DateLastRead DESC + `, + ).map((row) => ({ + ContentID: String(row.ContentID), + Title: str(row.Title), + Author: str(row.Author) || 'Unknown Author', + Description: str(row.Description), + Publisher: str(row.Publisher), + Series: str(row.Series), + SeriesNumber: str(row.SeriesNumber), + ISBN: str(row.ISBN), + Language: str(row.Language), + Progress: row.Progress ? Math.round(num(row.Progress)) : 0, + ReadStatus: row.ReadStatus === null ? null : num(row.ReadStatus), + DateCreated: toDate(row.DateCreated), + DateLastRead: toDate(row.DateLastRead), + FilePath: row.FilePath ? safeDecode(String(row.FilePath).replace('file://', '')) : null, + CoverId: str(row.CoverId), + TimeSpentReading: num(row.TimeSpentReading), + MimeType: str(row.MimeType), + })), + ); + } + + /** Highlights and notes attached to books. */ + getAnnotations(): KoboAnnotation[] { + return this.query('annotations', () => + this.rows( + ` + SELECT + b.BookmarkID, b.VolumeID, b.Text AS HighlightedText, b.Annotation AS Note, + b.DateCreated, b.DateModified, b.StartContainerPath, b.StartOffset, + b.EndContainerPath, b.EndOffset, c.Title AS BookTitle, c.Attribution AS Author + FROM Bookmark b + LEFT JOIN content c ON b.VolumeID = c.ContentID + WHERE (b.Text IS NOT NULL OR b.Annotation IS NOT NULL) + AND c.ContentType = 6 + ORDER BY b.DateCreated DESC + `, + ).map((row) => ({ + BookmarkID: String(row.BookmarkID), + VolumeID: String(row.VolumeID), + HighlightedText: str(row.HighlightedText), + Note: str(row.Note), + DateCreated: toDate(row.DateCreated), + DateModified: toDate(row.DateModified), + StartContainerPath: str(row.StartContainerPath), + StartOffset: row.StartOffset === null ? null : num(row.StartOffset), + EndContainerPath: str(row.EndContainerPath), + EndOffset: row.EndOffset === null ? null : num(row.EndOffset), + BookTitle: str(row.BookTitle) || 'Unknown Book', + Author: str(row.Author) || 'Unknown Author', + })), + ); + } + + getReadingStats(): ReadingStats { + return this.query('reading statistics', () => { + const stats = + this.rows(` + SELECT + COUNT(*) AS TotalBooks, + SUM(CASE WHEN ___PercentRead > 0 THEN 1 ELSE 0 END) AS BooksStarted, + SUM(CASE WHEN ___PercentRead >= 100 THEN 1 ELSE 0 END) AS BooksFinished, + SUM(CASE WHEN ReadStatus = 1 THEN 1 ELSE 0 END) AS CurrentlyReading, + SUM(TimeSpentReading) AS TotalMinutesRead, + AVG(___PercentRead) AS AverageProgress, + COUNT(DISTINCT Attribution) AS UniqueAuthors + FROM content + WHERE ContentType = 6 + AND BookTitle IS NULL + AND IsDownloaded = 'true' + AND ContentID LIKE 'file://%' + `)[0] ?? {}; + + return { + totalBooks: num(stats.TotalBooks ?? 0), + booksStarted: num(stats.BooksStarted ?? 0), + booksFinished: num(stats.BooksFinished ?? 0), + currentlyReading: num(stats.CurrentlyReading ?? 0), + totalMinutesRead: num(stats.TotalMinutesRead ?? 0), + averageProgress: stats.AverageProgress ? Math.round(num(stats.AverageProgress)) : 0, + uniqueAuthors: num(stats.UniqueAuthors ?? 0), + }; + }); + } + + getDeviceInfo(): DeviceInfo { + let model = 'Unknown Kobo Device'; + try { + if (this.rows('SELECT UserID FROM user LIMIT 1').length > 0) model = 'Kobo Device'; + } catch { + // Table might not exist + } + return { + model, + firmwareVersion: 'Unknown', + databaseVersion: this.getDatabaseVersion(), + schemaVersion: this.getSchemaVersion(), + }; + } + + /** SQLite library version. */ + getDatabaseVersion(): string { + try { + return str(this.rows('SELECT sqlite_version() AS version')[0]?.version ?? null) ?? 'Unknown'; + } catch { + return 'Unknown'; + } + } + + getSchemaVersion(): number { + try { + return num(this.rows('PRAGMA user_version')[0]?.user_version ?? 0); + } catch { + return 0; + } + } + + getCollections(): KoboCollection[] { + try { + return this.rows( + ` + SELECT Id, Name, InternalName, Type, CreationDate, LastModified + FROM Shelf + WHERE Type != 'SystemTag' + ORDER BY Name + `, + ).map((row) => ({ + Id: String(row.Id), + Name: String(row.Name), + InternalName: str(row.InternalName), + Type: str(row.Type), + CreationDate: toDate(row.CreationDate), + LastModified: toDate(row.LastModified), + })); + } catch { + return []; + } + } + + /** + * Prepare the database for writing back to a device: integrity check, + * rollback journal mode and VACUUM for a clean file. + */ + sanitize(): void { + try { + const integrity = this.db.exec('PRAGMA integrity_check'); + console.log('[DB] Integrity check result:', integrity[0]?.values[0]?.[0]); + this.db.exec('PRAGMA journaling_mode = DELETE'); + this.db.exec('VACUUM'); + } catch (error) { + throw new DatabaseError('Failed to sanitize database', ERROR_CODES.DB_WRITE_ERROR, { + originalError: error, + }); + } + } + + export(): Uint8Array { + return this.db.export() as Uint8Array; + } + + close(): void { + this.db.close(); + } +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +export function openDatabase(data: ArrayBuffer | Uint8Array): Promise { + return KoboDatabase.open(data); +} + +/** Extract everything the UI needs from a Kobo database in one pass. */ +export async function extractAllData(data: ArrayBuffer): Promise { + const db = await openDatabase(data); + try { + return { + books: db.getBooks(), + annotations: db.getAnnotations(), + stats: db.getReadingStats(), + deviceInfo: db.getDeviceInfo(), + collections: db.getCollections(), + databaseSize: data.byteLength, + }; + } finally { + db.close(); + } +} diff --git a/src/utils/realBackup.test.js b/src/utils/realBackup.test.js deleted file mode 100644 index 0486ad8..0000000 --- a/src/utils/realBackup.test.js +++ /dev/null @@ -1,391 +0,0 @@ -/** - * Integration tests against the user's two REAL Kobo backups + an in-memory - * File System Access mock so restore.js / fileSystem.js run end-to-end in node. - * - * Real fixtures (not committed, ~650 MB each) live in ./fixtures or $KOBUP_FIXTURES_DIR: - * - kobo_backup_2026-01-11.zip → OLD flat format (books/title.kepub.epub) - * - kobo_backup_2026-02-23.zip → NEW nested format (books/Author/title.kepub.epub) - * If a fixture is missing the corresponding tests skip (CI has no fixtures), - * while the synthetic-ZIP restore tests always run. - * - * sql.js' wasm path is hardcoded for the browser ("/sql-wasm.wasm"); we redirect - * it to the installed wasm so the real DB layer works under node — production - * code is untouched. - */ -import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; -import { openAsBlob, existsSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { join } from 'node:path'; -import { - ZipWriter, BlobWriter, TextReader, Uint8ArrayReader, - ZipReader, BlobReader, TextWriter, -} from '@zip.js/zip.js'; - -const WASM = fileURLToPath(new URL('../../node_modules/sql.js/dist/sql-wasm.wasm', import.meta.url)); -vi.mock('sql.js', async (importOriginal) => { - const mod = await importOriginal(); - const real = mod.default; - return { default: (cfg = {}) => real({ ...cfg, locateFile: () => WASM }) }; -}); - -// Imports that (transitively) load sql.js must come AFTER the mock declaration. -const { parseBackupFile, restoreToDevice } = await import('./restore.js'); -const { openDatabase } = await import('./koboDatabase.js'); -const { exportToAnkiCsv, exportToObsidianZip, generateObsidianMarkdown } = await import('./export.js'); -const initSqlJs = (await import('sql.js')).default; - -const FIXTURES = process.env.KOBUP_FIXTURES_DIR || fileURLToPath(new URL('../../fixtures/', import.meta.url)); -const ZIP_OLD = join(FIXTURES, 'kobo_backup_2026-01-11.zip'); -const ZIP_NEW = join(FIXTURES, 'kobo_backup_2026-02-23.zip'); -const haveOld = existsSync(ZIP_OLD); -const haveNew = existsSync(ZIP_NEW); - -/* ----------------------------- in-memory FS ----------------------------- */ - -class MemWritable { - constructor(fh) { this.fh = fh; } - async write(d) { this.fh._data = d; } - async close() {} -} -class MemFileHandle { - kind = 'file'; - constructor(name) { this.name = name; this._data = null; } - async createWritable() { return new MemWritable(this); } - async getFile() { - const d = this._data; - return new Blob(d == null ? [] : [d]); - } -} -class MemDirHandle { - kind = 'directory'; - constructor(name = 'root') { this.name = name; this._dirs = new Map(); this._files = new Map(); } - // Mirror the browser API which throws on "." / ".." / separators. - _check(n) { if (n === '.' || n === '..' || /[\\/]/.test(n)) throw new TypeError(`Name "${n}" is not allowed.`); } - async getDirectoryHandle(name, opts = {}) { - this._check(name); - if (this._dirs.has(name)) return this._dirs.get(name); - if (opts.create) { const d = new MemDirHandle(name); this._dirs.set(name, d); return d; } - const e = new Error('not found'); e.name = 'NotFoundError'; throw e; - } - async getFileHandle(name, opts = {}) { - this._check(name); - if (this._files.has(name)) return this._files.get(name); - if (opts.create) { const f = new MemFileHandle(name); this._files.set(name, f); return f; } - const e = new Error('not found'); e.name = 'NotFoundError'; throw e; - } - async removeEntry(name, opts = {}) { - this._check(name); - if (this._files.has(name)) { this._files.delete(name); return; } - if (this._dirs.has(name)) { - const d = this._dirs.get(name); - if (!opts.recursive && (d._dirs.size || d._files.size)) { - const e = new Error('not empty'); e.name = 'InvalidModificationError'; throw e; - } - this._dirs.delete(name); return; - } - const e = new Error('not found'); e.name = 'NotFoundError'; throw e; - } - async *values() { yield* this._dirs.values(); yield* this._files.values(); } - - // ---- test helpers ---- - dir(p) { return p.split('/').reduce((h, seg) => (seg ? h?._dirs.get(seg) : h), this); } - fileAt(path) { - const parts = path.split('/'); - const name = parts.pop(); - const d = this.dir(parts.join('/')); - return d?._files.get(name) || null; - } - hasFile(path) { return !!this.fileAt(path); } - hasDir(path) { return !!this.dir(path); } -} - -/* -------------------------- synthetic DB + ZIP --------------------------- */ - -async function buildKoboDb(books = [], bookmarks = []) { - const SQL = await initSqlJs(); - const db = new SQL.Database(); - db.run(`CREATE TABLE content ( - ContentID TEXT, Title TEXT, Attribution TEXT, Description TEXT, Publisher TEXT, - Series TEXT, SeriesNumber TEXT, ISBN TEXT, Language TEXT, ___PercentRead INTEGER, - ReadStatus INTEGER, DateCreated TEXT, DateLastRead TEXT, ImageId TEXT, - TimeSpentReading INTEGER, MimeType TEXT, ContentType INTEGER, IsDownloaded TEXT, - BookTitle TEXT - );`); - db.run(`CREATE TABLE Bookmark ( - BookmarkID TEXT, VolumeID TEXT, Text TEXT, Annotation TEXT, DateCreated TEXT, - DateModified TEXT, StartContainerPath TEXT, StartOffset INTEGER, - EndContainerPath TEXT, EndOffset INTEGER - );`); - for (const b of books) { - db.run( - `INSERT INTO content (ContentID,Title,Attribution,___PercentRead,ReadStatus,ImageId,TimeSpentReading,MimeType,ContentType,IsDownloaded,BookTitle,ISBN,Publisher,DateLastRead) - VALUES (?,?,?,?,?,?,?,?,6,'true',NULL,?,?,?)`, - [b.contentId, b.title, b.author ?? 'Auth', b.percent ?? 0, b.readStatus ?? 0, - b.imageId ?? 'img1', b.time ?? 0, b.mime ?? 'application/epub+zip', - b.isbn ?? '111', b.publisher ?? 'Pub', b.dateLastRead ?? '2026-01-01T00:00:00Z'], - ); - } - for (const m of bookmarks) { - db.run( - `INSERT INTO Bookmark (BookmarkID,VolumeID,Text,Annotation,DateCreated) VALUES (?,?,?,?,?)`, - [m.id, m.volumeId, m.text ?? null, m.note ?? null, m.date ?? '2026-01-01T00:00:00Z'], - ); - } - const buf = db.export(); - db.close(); - return buf; // Uint8Array -} - -async function buildBackupZip({ dbBytes, metadata, bookEntries = [] }) { - const zw = new ZipWriter(new BlobWriter('application/zip')); - await zw.add('backup-metadata.json', new TextReader(JSON.stringify(metadata))); - await zw.add('KoboReader.sqlite', new Uint8ArrayReader(dbBytes)); - for (const e of bookEntries) { - await zw.add(e.name, new TextReader(e.content ?? 'EPUBDATA')); - } - return await zw.close(); // Blob -} - -function meta(over = {}) { - return { - version: '1.0.0', - created: '2026-02-23T20:29:00.000Z', - statistics: { totalBooks: 1, totalAnnotations: 0 }, - device: { model: 'Kobo', firmwareVersion: '4.0' }, - options: {}, - ...over, - }; -} - -async function freshDevice() { - const dev = new MemDirHandle('device'); - await dev.getDirectoryHandle('.kobo', { create: true }); // restore requires it - return dev; -} - -/* --------------------------- real-zip fixtures --------------------------- */ - -const real = {}; // { old:{parsed,db,books,anns,stats}, new:{...} } - -beforeAll(async () => { - if (haveOld) { - const parsed = await parseBackupFile(await openAsBlob(ZIP_OLD)); - const db = await openDatabase(parsed.database); - real.old = { - parsed, db, - books: await db.getBooks(), - anns: await db.getAnnotations(), - stats: await db.getReadingStats(), - }; - } - if (haveNew) { - const parsed = await parseBackupFile(await openAsBlob(ZIP_NEW)); - const db = await openDatabase(parsed.database); - real.new = { - parsed, db, - books: await db.getBooks(), - anns: await db.getAnnotations(), - stats: await db.getReadingStats(), - }; - } -}, 120_000); - -afterAll(() => { - real.old?.db?.close(); - real.new?.db?.close(); -}); - -/* ================================ TESTS ================================= */ - -describe('REAL backup — parsing & format detection', () => { - it.skipIf(!haveOld)('1. parses OLD flat backup as valid with book files', () => { - expect(real.old.parsed.valid).toBe(true); - expect(real.old.parsed.bookFiles.length).toBeGreaterThan(0); - }); - - it.skipIf(!haveNew)('2. parses NEW nested backup as valid with book files', () => { - expect(real.new.parsed.valid).toBe(true); - expect(real.new.parsed.bookFiles.length).toBeGreaterThan(0); - }); - - it.skipIf(!haveNew)('3. NEW format embeds nested path directly in originalPath', () => { - const nested = real.new.parsed.bookFiles.find((b) => b.originalPath.includes('/')); - expect(nested).toBeTruthy(); - expect(nested.path).toBe(`books/${nested.originalPath}`); - }); - - it.skipIf(!haveOld)('4. OLD format resolves flat filename to a DB path via pathMap', () => { - expect(real.old.parsed.bookPathMap.size).toBeGreaterThan(0); - // every bookFile has a resolvable original path (filename or mapped) - expect(real.old.parsed.bookFiles.every((b) => typeof b.originalPath === 'string' && b.originalPath.length)).toBe(true); - }); - - it.skipIf(!haveOld)('5. parsed book filenames never contain path separators', () => { - expect(real.old.parsed.bookFiles.every((b) => !b.name.includes('/'))).toBe(true); - }); -}); - -describe('REAL backup — database schema queries', () => { - it.skipIf(!haveOld)('6. getBooks returns file:// ContentIDs and 0–100 integer progress', () => { - const b = real.old.books; - expect(b.length).toBeGreaterThan(0); - expect(b.every((x) => String(x.ContentID).startsWith('file://'))).toBe(true); - expect(b.every((x) => Number.isInteger(x.Progress) && x.Progress >= 0 && x.Progress <= 100)).toBe(true); - }); - - it.skipIf(!haveOld)('7. getReadingStats roughly matches metadata totalBooks', () => { - const metaTotal = real.old.parsed.metadata.statistics.totalBooks; - expect(real.old.stats.totalBooks).toBeGreaterThan(0); - // same order of magnitude (store vs sideloaded counting differs slightly) - expect(Math.abs(real.old.stats.totalBooks - metaTotal)).toBeLessThan(metaTotal * 0.5 + 5); - }); - - it.skipIf(!haveNew)('8. NEW backup books expose CoverId + Title fields', () => { - const b = real.new.books; - expect(b.length).toBeGreaterThan(0); - expect(b.every((x) => 'CoverId' in x && 'Title' in x)).toBe(true); - }); - - it.skipIf(!haveOld)('9. getAnnotations never throws and yields well-formed rows', () => { - expect(Array.isArray(real.old.anns)).toBe(true); - for (const a of real.old.anns.slice(0, 50)) { - expect('VolumeID' in a).toBe(true); - expect(a.HighlightedText != null || a.Note != null).toBe(true); - } - }); - - it.skipIf(!haveNew)('10. annotation VolumeIDs map onto book ContentIDs (dashboard grouping)', () => { - if (real.new.anns.length === 0) return; // nothing to assert - const ids = new Set(real.new.books.map((b) => b.ContentID)); - const matched = real.new.anns.filter((a) => ids.has(a.VolumeID)).length; - expect(matched).toBeGreaterThan(0); // grouping by VolumeID === ContentID works - }); - - it.skipIf(!haveOld)('11. reading stats contain no NaN', () => { - for (const v of Object.values(real.old.stats)) { - if (typeof v === 'number') expect(Number.isNaN(v)).toBe(false); - } - }); -}); - -describe('REAL backup — exporters on real data', () => { - it.skipIf(!haveNew)('12. Anki CSV from real annotations: header + one row per annotation, balanced quotes', async () => { - const anns = real.new.anns.length ? real.new.anns : [{ BookTitle: 'X', Author: 'Y', HighlightedText: 'h', Note: '' }]; - const csv = await exportToAnkiCsv(anns.slice(0, 200)).text(); - expect(csv.split('\n')[0]).toBe('Front,Back,Tags'); - expect((csv.match(/"/g) || []).length % 2).toBe(0); // every quote is closed - }); - - it.skipIf(!haveNew)('13. Obsidian ZIP from real library produces unique .md entries', async () => { - const annsByBook = {}; - real.new.anns.forEach((a) => { (annsByBook[a.VolumeID] ||= []).push(a); }); - const blob = await exportToObsidianZip(real.new.books, annsByBook).catch((e) => e); - if (blob instanceof Error) { - expect(blob.message).toMatch(/No annotations/); // valid outcome if library has none - return; - } - const zr = new ZipReader(new BlobReader(blob)); - const names = (await zr.getEntries()).map((e) => e.filename); - await zr.close(); - expect(new Set(names).size).toBe(names.length); // no collisions - expect(names.every((n) => n.endsWith('.md'))).toBe(true); - }); - - it.skipIf(!haveOld)('14. Obsidian markdown for a real book carries YAML frontmatter', () => { - const book = real.old.books[0]; - const md = generateObsidianMarkdown(book, [{ HighlightedText: 'hi', Note: 'n', DateCreated: '2026-01-01' }]); - expect(md.startsWith('---')).toBe(true); - expect(md).toContain('source: Kobo'); - expect(md).toContain(`# ${book.Title}`); - }); -}); - -describe('SYNTHETIC restore — in-memory device end-to-end', () => { - it('15. NEW-format restore writes books to nested author folders', async () => { - const dbBytes = await buildKoboDb([ - { contentId: 'file:///mnt/onboard/Author A/book1.epub', title: 'Book 1' }, - ]); - const zip = await buildBackupZip({ - dbBytes, - metadata: meta({ statistics: { totalBooks: 1 } }), - bookEntries: [{ name: 'books/Author A/book1.epub', content: 'B1' }], - }); - const backupData = await parseBackupFile(zip); - const dev = await freshDevice(); - const res = await restoreToDevice(dev, backupData, {}); - expect(res.success).toBe(true); - expect(res.failedBooks).toHaveLength(0); - expect(dev.hasFile('Author A/book1.epub')).toBe(true); - expect(dev.hasFile('.kobo/KoboReader.sqlite')).toBe(true); - }); - - it('16. default restore (cleanExistingBooks=false) keeps pre-existing files', async () => { - const dbBytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/Author A/book1.epub', title: 'B1' }]); - const zip = await buildBackupZip({ dbBytes, metadata: meta(), bookEntries: [{ name: 'books/Author A/book1.epub' }] }); - const backupData = await parseBackupFile(zip); - const dev = await freshDevice(); - // pre-existing user file added AFTER the backup, inside same author folder - const authorDir = await dev.getDirectoryHandle('Author A', { create: true }); - const keep = await authorDir.getFileHandle('added-later.epub', { create: true }); - (await keep.createWritable()).write('keep'); - - await restoreToDevice(dev, backupData, {}); // default: no clean - expect(dev.hasFile('Author A/added-later.epub')).toBe(true); // NOT wiped - expect(dev.hasFile('Author A/book1.epub')).toBe(true); - }); - - it('17. opt-in cleanExistingBooks=true removes the author folder before restore', async () => { - const dbBytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/Author A/book1.epub', title: 'B1' }]); - const zip = await buildBackupZip({ dbBytes, metadata: meta(), bookEntries: [{ name: 'books/Author A/book1.epub' }] }); - const backupData = await parseBackupFile(zip); - const dev = await freshDevice(); - const authorDir = await dev.getDirectoryHandle('Author A', { create: true }); - const stale = await authorDir.getFileHandle('stale.epub', { create: true }); - (await stale.createWritable()).write('stale'); - - await restoreToDevice(dev, backupData, { cleanExistingBooks: true }); - expect(dev.hasFile('Author A/stale.epub')).toBe(false); // folder was cleaned - expect(dev.hasFile('Author A/book1.epub')).toBe(true); // then restored - }); - - it('18. cleanup never deletes a protected dir (.kobo) even if a book path points there', async () => { - // Malicious/corrupt DB row whose path resolves under .kobo - const dbBytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/.kobo/evil.epub', title: 'Evil' }]); - const zip = await buildBackupZip({ dbBytes, metadata: meta(), bookEntries: [{ name: 'books/.kobo/evil.epub' }] }); - const backupData = await parseBackupFile(zip); - const dev = await freshDevice(); - // sentinel inside .kobo that must survive - const kobo = await dev.getDirectoryHandle('.kobo', { create: true }); - const sentinel = await kobo.getFileHandle('precious.db', { create: true }); - (await sentinel.createWritable()).write('precious'); - - await restoreToDevice(dev, backupData, { cleanExistingBooks: true }); - expect(dev.hasFile('.kobo/precious.db')).toBe(true); // protected - }); - - it('19. path-traversal book entry is rejected, never escapes device root', async () => { - const dbBytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/ok.epub', title: 'ok' }]); - const zip = await buildBackupZip({ dbBytes, metadata: meta(), bookEntries: [{ name: 'books/ok.epub' }] }); - const backupData = await parseBackupFile(zip); - // Inject a malicious originalPath the parser would never produce - backupData.bookFiles.push({ name: 'evil.epub', path: 'books/ok.epub', originalPath: '../../../evil.epub' }); - - const dev = await freshDevice(); - const res = await restoreToDevice(dev, backupData, {}); - const failed = res.failedBooks.find((f) => f.originalPath === '../../../evil.epub'); - expect(failed).toBeTruthy(); - expect(/traversal/i.test(failed.error)).toBe(true); - expect(dev.hasFile('evil.epub')).toBe(false); - }); - - it('20. database-only restore (includeBooks=false) writes DB and skips books', async () => { - const dbBytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/Author A/book1.epub', title: 'B1' }]); - const zip = await buildBackupZip({ dbBytes, metadata: meta(), bookEntries: [{ name: 'books/Author A/book1.epub' }] }); - const backupData = await parseBackupFile(zip); - const dev = await freshDevice(); - const res = await restoreToDevice(dev, backupData, { includeBooks: false }); - expect(res.success).toBe(true); - expect(dev.hasFile('.kobo/KoboReader.sqlite')).toBe(true); - expect(dev.hasFile('Author A/book1.epub')).toBe(false); // books skipped - }); -}); diff --git a/src/utils/realBackup.test.ts b/src/utils/realBackup.test.ts new file mode 100644 index 0000000..0b36b29 --- /dev/null +++ b/src/utils/realBackup.test.ts @@ -0,0 +1,244 @@ +/** + * Integration tests against the user's REAL Kobo backups + an in-memory File + * System Access mock so restore.ts / fileSystem.ts run end-to-end in node. + * + * Real fixtures (not committed, ~650 MB each) live in ./fixtures or $KOBUP_FIXTURES_DIR: + * - kobo_backup_2026-01-11.zip → OLD flat format (books/title.kepub.epub) + * - kobo_backup_2026-02-23.zip → NEW nested format (books/Author/title.kepub.epub) + * Missing fixtures skip the corresponding tests (CI), synthetic tests always run. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import { openAsBlob } from 'node:fs'; +import { ZipReader, BlobReader } from '@zip.js/zip.js'; +import { buildBackupZip, buildKoboDb, fixture, meta } from '../test/builders.ts'; +import { freshDevice } from '../test/memoryFs.ts'; +import type { KoboAnnotation, KoboBook, ReadingStats } from '../types/kobo.ts'; + +vi.mock('sql.js', async (importOriginal) => + (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), +); + +const { parseBackupFile, restoreToDevice } = await import('./restore.ts'); +const { openDatabase } = await import('./koboDatabase.ts'); +const { exportToAnkiCsv, exportToObsidianZip, generateObsidianMarkdown } = await import('./export.ts'); + +type Parsed = Awaited>; +type Db = Awaited>; +interface RealFixture { + parsed: Parsed; + db: Db; + books: KoboBook[]; + anns: KoboAnnotation[]; + stats: ReadingStats; +} + +const ZIP_OLD = fixture('kobo_backup_2026-01-11.zip'); +const ZIP_NEW = fixture('kobo_backup_2026-02-23.zip'); +const real: { old?: RealFixture; new?: RealFixture } = {}; + +async function loadFixture(path: string): Promise { + const parsed = await parseBackupFile(await openAsBlob(path)); + const db = await openDatabase(parsed.database); + return { parsed, db, books: db.getBooks(), anns: db.getAnnotations(), stats: db.getReadingStats() }; +} + +beforeAll(async () => { + if (ZIP_OLD) real.old = await loadFixture(ZIP_OLD); + if (ZIP_NEW) real.new = await loadFixture(ZIP_NEW); +}, 120_000); + +afterAll(() => { + real.old?.db.close(); + real.new?.db.close(); +}); + +describe('REAL backup — parsing & format detection', () => { + it.skipIf(!ZIP_OLD)('1. parses OLD flat backup as valid with book files', () => { + expect(real.old!.parsed.valid).toBe(true); + expect(real.old!.parsed.bookFiles.length).toBeGreaterThan(0); + }); + + it.skipIf(!ZIP_NEW)('2. parses NEW nested backup as valid with book files', () => { + expect(real.new!.parsed.valid).toBe(true); + expect(real.new!.parsed.bookFiles.length).toBeGreaterThan(0); + }); + + it.skipIf(!ZIP_NEW)('3. NEW format embeds nested path directly in originalPath', () => { + const nested = real.new!.parsed.bookFiles.find((b) => b.originalPath.includes('/')); + expect(nested).toBeTruthy(); + expect(nested!.path).toBe(`books/${nested!.originalPath}`); + }); + + it.skipIf(!ZIP_OLD)('4. OLD format resolves flat filename to a DB path via pathMap', () => { + expect(real.old!.parsed.bookPathMap.size).toBeGreaterThan(0); + expect( + real.old!.parsed.bookFiles.every((b) => typeof b.originalPath === 'string' && b.originalPath.length), + ).toBe(true); + }); + + it.skipIf(!ZIP_OLD)('5. parsed book filenames never contain path separators', () => { + expect(real.old!.parsed.bookFiles.every((b) => !b.name.includes('/'))).toBe(true); + }); +}); + +describe('REAL backup — database schema queries', () => { + it.skipIf(!ZIP_OLD)('6. getBooks returns file:// ContentIDs and 0–100 integer progress', () => { + const b = real.old!.books; + expect(b.length).toBeGreaterThan(0); + expect(b.every((x) => x.ContentID.startsWith('file://'))).toBe(true); + expect(b.every((x) => Number.isInteger(x.Progress) && x.Progress >= 0 && x.Progress <= 100)).toBe(true); + }); + + it.skipIf(!ZIP_OLD)('7. getReadingStats roughly matches metadata totalBooks', () => { + const metaTotal = real.old!.parsed.metadata.statistics.totalBooks; + expect(real.old!.stats.totalBooks).toBeGreaterThan(0); + expect(Math.abs(real.old!.stats.totalBooks - metaTotal)).toBeLessThan(metaTotal * 0.5 + 5); + }); + + it.skipIf(!ZIP_NEW)('8. NEW backup books expose CoverId + Title fields', () => { + const b = real.new!.books; + expect(b.length).toBeGreaterThan(0); + expect(b.every((x) => 'CoverId' in x && 'Title' in x)).toBe(true); + }); + + it.skipIf(!ZIP_OLD)('9. getAnnotations never throws and yields well-formed rows', () => { + for (const a of real.old!.anns.slice(0, 50)) { + expect('VolumeID' in a).toBe(true); + expect(a.HighlightedText != null || a.Note != null).toBe(true); + } + }); + + it.skipIf(!ZIP_NEW)('10. annotation VolumeIDs map onto book ContentIDs (dashboard grouping)', () => { + if (real.new!.anns.length === 0) return; + const ids = new Set(real.new!.books.map((b) => b.ContentID)); + expect(real.new!.anns.filter((a) => ids.has(a.VolumeID)).length).toBeGreaterThan(0); + }); + + it.skipIf(!ZIP_OLD)('11. reading stats contain no NaN', () => { + for (const v of Object.values(real.old!.stats)) expect(Number.isNaN(v)).toBe(false); + }); +}); + +describe('REAL backup — exporters on real data', () => { + it.skipIf(!ZIP_NEW)('12. Anki CSV from real annotations: header + balanced quotes', async () => { + const anns = real.new!.anns.length + ? real.new!.anns + : [{ BookTitle: 'X', Author: 'Y', HighlightedText: 'h', Note: '' }]; + const csv = await exportToAnkiCsv(anns.slice(0, 200)).text(); + expect(csv.split('\n')[0]).toBe('Front,Back,Tags'); + expect((csv.match(/"/g) || []).length % 2).toBe(0); + }); + + it.skipIf(!ZIP_NEW)('13. Obsidian ZIP from real library produces unique .md entries', async () => { + const annsByBook: Record = {}; + real.new!.anns.forEach((a) => (annsByBook[a.VolumeID] ||= []).push(a)); + const blob = await exportToObsidianZip(real.new!.books, annsByBook).catch((e: Error) => e); + if (blob instanceof Error) { + expect(blob.message).toMatch(/No annotations/); + return; + } + const zr = new ZipReader(new BlobReader(blob)); + const names = (await zr.getEntries()).map((e) => e.filename); + await zr.close(); + expect(new Set(names).size).toBe(names.length); + expect(names.every((n) => n.endsWith('.md'))).toBe(true); + }); + + it.skipIf(!ZIP_OLD)('14. Obsidian markdown for a real book carries YAML frontmatter', () => { + const book = real.old!.books[0]!; + const md = generateObsidianMarkdown(book, [ + { HighlightedText: 'hi', Note: 'n', DateCreated: new Date('2026-01-01') }, + ]); + expect(md.startsWith('---')).toBe(true); + expect(md).toContain('source: Kobo'); + expect(md).toContain(`# ${book.Title}`); + }); +}); + +describe('SYNTHETIC restore — in-memory device end-to-end', () => { + const oneBook = async () => { + const dbBytes = await buildKoboDb([ + { contentId: 'file:///mnt/onboard/Author A/book1.epub', title: 'B1' }, + ]); + const zip = await buildBackupZip({ + dbBytes, + metadata: meta(), + bookEntries: [{ name: 'books/Author A/book1.epub' }], + }); + return parseBackupFile(zip); + }; + + it('15. NEW-format restore writes books to nested author folders', async () => { + const backupData = await oneBook(); + const dev = await freshDevice(); + const res = await restoreToDevice(dev.handle, backupData, {}); + expect(res.success).toBe(true); + expect(res.failedBooks).toHaveLength(0); + expect(dev.hasFile('Author A/book1.epub')).toBe(true); + expect(dev.hasFile('.kobo/KoboReader.sqlite')).toBe(true); + }); + + it('16. default restore (cleanExistingBooks=false) keeps pre-existing files', async () => { + const backupData = await oneBook(); + const dev = await freshDevice(); + await dev.writeText('Author A/added-later.epub', 'keep'); + + await restoreToDevice(dev.handle, backupData, {}); + expect(dev.hasFile('Author A/added-later.epub')).toBe(true); + expect(dev.hasFile('Author A/book1.epub')).toBe(true); + }); + + it('17. opt-in cleanExistingBooks=true removes the author folder before restore', async () => { + const backupData = await oneBook(); + const dev = await freshDevice(); + await dev.writeText('Author A/stale.epub', 'stale'); + + await restoreToDevice(dev.handle, backupData, { cleanExistingBooks: true }); + expect(dev.hasFile('Author A/stale.epub')).toBe(false); + expect(dev.hasFile('Author A/book1.epub')).toBe(true); + }); + + it('18. cleanup never deletes a protected dir (.kobo) even if a book path points there', async () => { + const dbBytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/.kobo/evil.epub', title: 'Evil' }]); + const zip = await buildBackupZip({ + dbBytes, + metadata: meta(), + bookEntries: [{ name: 'books/.kobo/evil.epub' }], + }); + const backupData = await parseBackupFile(zip); + const dev = await freshDevice(); + await dev.writeText('.kobo/precious.db', 'precious'); + + await restoreToDevice(dev.handle, backupData, { cleanExistingBooks: true }); + expect(dev.hasFile('.kobo/precious.db')).toBe(true); + }); + + it('19. path-traversal book entry is rejected, never escapes device root', async () => { + const dbBytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/ok.epub', title: 'ok' }]); + const zip = await buildBackupZip({ dbBytes, metadata: meta(), bookEntries: [{ name: 'books/ok.epub' }] }); + const backupData = await parseBackupFile(zip); + // Inject a malicious originalPath the parser would never produce + backupData.bookFiles.push({ + name: 'evil.epub', + path: 'books/ok.epub', + originalPath: '../../../evil.epub', + }); + + const dev = await freshDevice(); + const res = await restoreToDevice(dev.handle, backupData, {}); + const failed = res.failedBooks.find((f) => f.originalPath === '../../../evil.epub'); + expect(failed).toBeTruthy(); + expect(failed!.error).toMatch(/traversal/i); + expect(dev.hasFile('evil.epub')).toBe(false); + }); + + it('20. database-only restore (includeBooks=false) writes DB and skips books', async () => { + const backupData = await oneBook(); + const dev = await freshDevice(); + const res = await restoreToDevice(dev.handle, backupData, { includeBooks: false }); + expect(res.success).toBe(true); + expect(res.booksRestored).toBe(0); + expect(dev.hasFile('.kobo/KoboReader.sqlite')).toBe(true); + expect(dev.hasFile('Author A/book1.epub')).toBe(false); + }); +}); diff --git a/src/utils/restore.js b/src/utils/restore.js deleted file mode 100644 index c1142f3..0000000 --- a/src/utils/restore.js +++ /dev/null @@ -1,612 +0,0 @@ -/** - * Restore utilities for Kobo Backup Manager - * Handles extracting and restoring backup ZIPs to Kobo devices - */ - -import { ZipReader, BlobReader, BlobWriter, TextWriter } from '@zip.js/zip.js'; -import { writeFile, writeFileToPath } from './fileSystem.js'; -import { RestoreError, ERROR_CODES } from './errors.js'; -import { validateBackupMetadata } from './validation.js'; -import { openDatabase } from './koboDatabase.js'; - -/** - * Parse and validate a backup ZIP file - * @param {File|Blob} file - Backup ZIP file - * @returns {Promise} Parsed backup data - */ -export async function parseBackupFile(file) { - let zipReader; - try { - // Load ZIP - zipReader = new ZipReader(new BlobReader(file)); - const entries = await zipReader.getEntries(); - - // Check for required files - const metadataEntry = entries.find(e => e.filename === 'backup-metadata.json'); - const dbEntry = entries.find(e => e.filename === 'KoboReader.sqlite'); - - if (!metadataEntry) { - throw new RestoreError( - 'Invalid backup: missing backup-metadata.json', - ERROR_CODES.RESTORE_INVALID_FILE, - { missingFile: 'backup-metadata.json' } - ); - } - if (!dbEntry) { - throw new RestoreError( - 'Invalid backup: missing KoboReader.sqlite', - ERROR_CODES.RESTORE_INVALID_FILE, - { missingFile: 'KoboReader.sqlite' } - ); - } - - // Parse metadata - const metadataText = await metadataEntry.getData(new TextWriter()); - const metadata = JSON.parse(metadataText); - - // Validate metadata - const validation = validateBackupMetadata(metadata); - if (!validation.valid) { - throw new RestoreError( - `Invalid backup metadata: ${validation.error}`, - ERROR_CODES.RESTORE_INVALID_FILE, - { validationError: validation.error } - ); - } - - // Extract database - const dbBlob = await dbEntry.getData(new BlobWriter()); - const database = await dbBlob.arrayBuffer(); - - // Extract original book paths from database - const bookPathMap = await extractBookPathsFromDatabase(database); - - // Get list of book files with their original paths - const bookFiles = []; - - for (const entry of entries) { - const path = entry.filename; - if (path.startsWith('books/') && !entry.directory) { - // zipRelPath: the part after "books/" - // New backup format: "Libri/Author/book.epub" (full relative path embedded in ZIP) - // Old backup format: "book.epub" (filename only, path recovered via pathMap) - const zipRelPath = path.replace('books/', ''); - const displayName = zipRelPath.split('/').pop(); - - let originalPath; - if (zipRelPath.includes('/')) { - // New format: path is already embedded in the ZIP structure — use it directly. - // This is the most reliable approach since it doesn't depend on the database lookup. - originalPath = zipRelPath; - } else { - // Old backup format (backwards compatibility): - // look up the full path from the database-derived pathMap. - originalPath = bookPathMap.get(zipRelPath) || zipRelPath; - } - - bookFiles.push({ - name: displayName, - path: path, - originalPath: originalPath, - }); - } - } - - return { - metadata, - database, - bookFiles, - bookPathMap, - file, - valid: true, - }; - } catch (error) { - if (error instanceof RestoreError) { - throw error; - } - - throw new RestoreError( - 'Failed to parse backup file', - ERROR_CODES.RESTORE_CORRUPTED, - { originalError: error } - ); - } finally { - if (zipReader) { - await zipReader.close(); - } - } -} - -/** - * Restore backup to a Kobo device - * @param {FileSystemDirectoryHandle} deviceHandle - Kobo device directory handle - * @param {object} backupData - Parsed backup data from parseBackupFile() - * @param {object} options - Restore options - * @returns {Promise} Restore result - */ -export async function restoreToDevice(deviceHandle, backupData, options = {}) { - const { - includeBooks = true, - includeAnnotations = true, - includeProgress = true, - cleanExistingBooks = false, // Opt-in: recursively removes top-level book folders - // before restore. Off by default because it can delete - // files added to those folders AFTER the backup was made. - onProgress = null, - } = options; - - try { - const reportProgress = (stage, percent, details = {}) => { - if (onProgress) { - onProgress({ stage, percent, ...details }); - } - }; - - reportProgress('Preparing device...', 0); - - // Get .kobo folder - let koboFolder; - try { - koboFolder = await deviceHandle.getDirectoryHandle('.kobo'); - } catch (error) { - throw new RestoreError( - 'Could not find .kobo folder on device', - ERROR_CODES.RESTORE_FAILED, - { reason: 'Invalid Kobo device' } - ); - } - - // Clean existing books if requested - if (includeBooks && cleanExistingBooks && backupData.bookFiles.length > 0) { - reportProgress('Removing existing books...', 5); - - try { - const cleanedCount = await cleanExistingBooksFromDevice(deviceHandle, backupData.bookPathMap, reportProgress); - console.log(`[RESTORE] Cleaned ${cleanedCount} existing book files/folders`); - } catch (error) { - console.warn('[RESTORE] Failed to clean existing books:', error); - // Continue with restore even if cleanup fails - } - } - - // Restore database - reportProgress('Restoring database...', 15); - - try { - // CRITICAL: Delete existing WAL/SHM files to prevent database corruption - // If we overwrite the .sqlite file but leave old WAL files, SQLite will try to recover - // from the mismatched WAL, corrupting the database. - try { - await koboFolder.removeEntry('KoboReader.sqlite-wal'); - console.log('[RESTORE] Removed existing WAL file'); - } catch (e) { /* Warning is fine, file might not exist */ } - - try { - await koboFolder.removeEntry('KoboReader.sqlite-shm'); - console.log('[RESTORE] Removed existing SHM file'); - } catch (e) { /* Warning is fine */ } - - // NEW: Sanitize database before writing - reportProgress('Sanitizing database...', 18); - let dbData = backupData.database; - try { - const db = await openDatabase(backupData.database); - await db.sanitize(); - dbData = await db.export(); - // db.close() is handled by garbage collection or we could add close() if we refactor, - // but openDatabase returns a new instance. Ideally we should close it. - db.close(); - console.log('[RESTORE] Database sanitized and exported'); - } catch (error) { - console.warn('[RESTORE] Database sanitization failed, using original:', error); - // Fallback to original - } - - await writeFile(koboFolder, 'KoboReader.sqlite', dbData); - } catch (error) { - throw new RestoreError( - 'Failed to write database to device', - ERROR_CODES.RESTORE_FAILED, - { originalError: error } - ); - } - - reportProgress('Database restored', 20); - - // Restore books - const failedBooks = []; - if (includeBooks && backupData.bookFiles.length > 0) { - const totalBooks = backupData.bookFiles.length; - let restoredBooks = 0; - - const zipReader = new ZipReader(new BlobReader(backupData.file)); - try { - const entries = await zipReader.getEntries(); - - for (let i = 0; i < backupData.bookFiles.length; i++) { - const bookFile = backupData.bookFiles[i]; - - // Find matching entry in ZIP by path - const zipEntry = entries.find(e => e.filename === bookFile.path); - if (!zipEntry) { - console.error(`Book entry not found in backup archive: ${bookFile.path}`); - failedBooks.push({ - name: bookFile.name, - originalPath: bookFile.originalPath, - error: 'File not found in ZIP archive', - }); - continue; - } - - // Retry up to 2 times before marking a book as failed - let lastError = null; - let success = false; - - for (let attempt = 1; attempt <= 2; attempt++) { - try { - // Extract book file from ZIP - const bookBlob = await zipEntry.getData(new BlobWriter()); - - // Log the path being used (first 5 books only) - if (i < 5) { - console.log(`[RESTORE] Book ${i + 1}: "${bookFile.name}" -> "${bookFile.originalPath}"`); - } - - // Write to original path (preserves database ContentID references). - // The Kobo matches book files to DB records via ContentID which encodes - // the full path (file:///mnt/onboard/). If this path is - // wrong, the Kobo creates a new empty record and progress/annotations - // are lost. - await writeFileToPath(deviceHandle, bookFile.originalPath, bookBlob); - - success = true; - break; - } catch (error) { - lastError = error; - if (attempt < 2) { - // Brief pause before retry - await new Promise(resolve => setTimeout(resolve, 300)); - } - } - } - - if (success) { - restoredBooks++; - } else { - console.error(`Failed to restore book after retries: ${bookFile.name}`, lastError); - failedBooks.push({ - name: bookFile.name, - originalPath: bookFile.originalPath, - error: lastError?.message || 'Unknown error', - }); - } - - const progress = 20 + ((i + 1) / totalBooks) * 65; // 20-85% - reportProgress( - `Restoring books (${i + 1}/${totalBooks})...`, - progress, - { filesProcessed: i + 1, totalFiles: totalBooks } - ); - } - } finally { - await zipReader.close(); - } - - reportProgress('Books restored', 85); - } - - // Post-restore validation: reopen the database from the device and count books - reportProgress('Verifying restore...', 90); - let verification = null; - try { - const restoredDbHandle = await koboFolder.getFileHandle('KoboReader.sqlite'); - const restoredDbBuffer = await (await restoredDbHandle.getFile()).arrayBuffer(); - const verifyDb = await openDatabase(restoredDbBuffer); - const verifyBooks = await verifyDb.getBooks(); - verifyDb.close(); - - const dbBooksCount = verifyBooks.length; - const expectedCount = backupData.metadata?.statistics?.totalBooks || 0; - verification = { - dbBooksCount, - expectedCount, - ok: expectedCount === 0 || dbBooksCount >= expectedCount, - }; - console.log(`[RESTORE] Verification: ${dbBooksCount}/${expectedCount} books in restored DB`); - } catch (verifyError) { - console.warn('[RESTORE] Post-restore verification failed:', verifyError); - } - - // Final steps - reportProgress('Finalizing restore...', 95); - reportProgress('Restore complete', 100); - - return { - success: true, - booksRestored: backupData.bookFiles.length - failedBooks.length, - failedBooks, - databaseRestored: true, - metadata: backupData.metadata, - verification, - }; - } catch (error) { - if (error instanceof RestoreError) { - throw error; - } - - throw new RestoreError( - 'Failed to restore backup to device', - ERROR_CODES.RESTORE_FAILED, - { originalError: error } - ); - } -} - -/** - * Check compatibility between backup and target device - * @param {object} backupMetadata - Backup metadata - * @param {object} deviceInfo - Target device info - * @returns {{compatible: boolean, warnings: string[]}} Compatibility check result - */ -export function checkCompatibility(backupMetadata, deviceInfo) { - const warnings = []; - - // Check device model - if (backupMetadata.device?.model && deviceInfo?.model) { - if (backupMetadata.device.model !== deviceInfo.model) { - warnings.push( - `Backup is from a different device model (${backupMetadata.device.model} → ${deviceInfo.model}). Settings may not transfer correctly.` - ); - } - } - - // Check firmware version - if (backupMetadata.device?.firmwareVersion && deviceInfo?.firmwareVersion) { - const backupVersion = backupMetadata.device.firmwareVersion; - const deviceVersion = deviceInfo.firmwareVersion; - - if (backupVersion !== deviceVersion) { - warnings.push( - `Different firmware versions (${backupVersion} → ${deviceVersion}). This should work but may have minor issues.` - ); - } - } - - // Check backup age - if (backupMetadata.created) { - const backupDate = new Date(backupMetadata.created); - const daysSinceBackup = (new Date() - backupDate) / (1000 * 60 * 60 * 24); - - if (daysSinceBackup > 180) { - warnings.push( - `This backup is ${Math.floor(daysSinceBackup)} days old. Your Kobo may have received firmware updates since then.` - ); - } - } - - return { - compatible: true, // Backups are generally forward-compatible - warnings, - }; -} - -/** - * Preview backup contents without restoring - * @param {object} backupData - Parsed backup data - * @returns {object} Backup preview information - */ -export function previewBackup(backupData) { - const { metadata, bookFiles } = backupData; - - return { - created: metadata.created, - deviceModel: metadata.device?.model || 'Unknown', - firmwareVersion: metadata.device?.firmwareVersion || 'Unknown', - - statistics: { - totalBooks: metadata.statistics?.totalBooks || 0, - totalAnnotations: metadata.statistics?.totalAnnotations || 0, - booksStarted: metadata.statistics?.booksStarted || 0, - booksFinished: metadata.statistics?.booksFinished || 0, - totalReadingTime: metadata.statistics?.totalReadingTime || 0, - }, - - contents: { - databaseIncluded: true, - booksIncluded: bookFiles.length > 0, - bookCount: bookFiles.length, - annotationsIncluded: metadata.options?.includeAnnotations || false, - }, - - options: metadata.options || {}, - }; -} - -/** - * Validate restore target device - * @param {FileSystemDirectoryHandle} deviceHandle - Device directory handle - * @returns {Promise<{valid: boolean, error?: string}>} Validation result - */ -export async function validateRestoreTarget(deviceHandle) { - try { - // Check for .kobo folder - let koboFolder; - try { - koboFolder = await deviceHandle.getDirectoryHandle('.kobo'); - } catch (error) { - return { - valid: false, - error: 'Not a valid Kobo device (missing .kobo folder)', - }; - } - - // Check if we can write to the device - try { - // Try to get write permission - const testFile = await deviceHandle.getFileHandle('_test_write.tmp', { create: true }); - // Clean up test file - await deviceHandle.removeEntry('_test_write.tmp'); - } catch (error) { - return { - valid: false, - error: 'Cannot write to device (check permissions)', - }; - } - - return { valid: true }; - } catch (error) { - return { - valid: false, - error: `Validation failed: ${error.message}`, - }; - } -} -/** - * Extract book file paths from backup database - * Creates a mapping from filename to original path (without file:// prefix) - * @param {ArrayBuffer} databaseBuffer - SQLite database as ArrayBuffer - * @returns {Promise>} Map of filename -> original relative path - */ -async function extractBookPathsFromDatabase(databaseBuffer) { - const pathMap = new Map(); - - try { - console.log('[RESTORE DEBUG] Opening database to extract book paths...'); - const db = await openDatabase(databaseBuffer); - const books = await db.getBooks(); - db.close(); - - console.log(`[RESTORE DEBUG] Found ${books.length} books in database`); - - // Log first few books to see the data structure - if (books.length > 0) { - console.log('[RESTORE DEBUG] Sample book data:', { - ContentID: books[0].ContentID, - FilePath: books[0].FilePath, - Title: books[0].Title - }); - } - - for (const book of books) { - // Use ContentID if available, otherwise try FilePath - const contentPath = book.ContentID || book.FilePath; - - if (contentPath) { - // ContentID format: "file:///mnt/onboard/path/to/book.epub" - // We need to extract the relative path from the Kobo mount point - let originalPath = contentPath; - - // Remove file:// prefix (note: file:///mnt/... → /mnt/... after removing file://) - if (originalPath.startsWith('file://')) { - originalPath = originalPath.replace('file://', ''); - } - - // Remove /mnt/onboard/ prefix (Kobo's internal mount point) - // This leaves us with the relative path from the device root - if (originalPath.startsWith('/mnt/onboard/')) { - originalPath = originalPath.replace('/mnt/onboard/', ''); - } - - // CRITICAL: URL-decode the path so filenames with spaces/special chars - // (stored as %20 etc. in ContentID) match the actual files in the ZIP. - // decodeURIComponent is a no-op for paths that aren't encoded. - try { - originalPath = decodeURIComponent(originalPath); - } catch (e) { - // If decoding fails (malformed URI), keep the original - } - - // Extract just the filename (for matching with backup files stored by name only) - const filename = originalPath.split('/').pop(); - - if (filename) { - // Only set if not already present: first occurrence wins. - // Duplicate filenames with different paths will use the first one. - if (!pathMap.has(filename)) { - pathMap.set(filename, originalPath); - } - } - } - } - - console.log(`[RESTORE DEBUG] Created path map with ${pathMap.size} entries`); - - // Log some sample mappings - const entries = Array.from(pathMap.entries()).slice(0, 3); - console.log('[RESTORE DEBUG] Sample path mappings:', entries); - - } catch (error) { - console.error('[RESTORE DEBUG] Failed to extract book paths from database:', error); - // Return empty map - will fall back to using just filename - } - - return pathMap; -} - -/** - * Clean existing book files from device before restore - * Removes book files that would conflict with the restore - * @param {FileSystemDirectoryHandle} deviceHandle - Device root handle - * @param {Map} bookPathMap - Map of filename -> original path - * @param {Function} reportProgress - Progress callback - * @returns {Promise} Number of items cleaned - */ -async function cleanExistingBooksFromDevice(deviceHandle, bookPathMap, reportProgress) { - let cleanedCount = 0; - - // Sets to track what to remove - const dirsToClean = new Set(); - const filesToClean = new Set(); - - // Never recursively delete these top-level entries even if a (malicious or - // corrupt) backup path resolves to them — they hold system/non-book data. - const PROTECTED_DIRS = new Set(['', '.', '..', '.kobo', '.adobe-digital-editions', 'System Volume Information']); - - for (const [filename, originalPath] of bookPathMap.entries()) { - // 1. Identify destination directories to clean (e.g. "AuthorName/") - const parts = originalPath.split('/'); - if (parts.length > 1 && !PROTECTED_DIRS.has(parts[0])) { - dirsToClean.add(parts[0]); - } - - // 2. ALWAYS identify the flat filename in root to clean - // This removes the "duplicates" caused by the previous buggy restore - // or manual flat copies - filesToClean.add(filename); - } - - console.log(`[RESTORE CLEANUP] Targets: ${dirsToClean.size} directories, ${filesToClean.size} root files`); - - // Remove top-level directories (author folders) - for (const dirName of dirsToClean) { - try { - await deviceHandle.removeEntry(dirName, { recursive: true }); - cleanedCount++; - console.log(`[RESTORE CLEANUP] Removed directory: ${dirName}`); - } catch (error) { - // Directory might not exist, that's fine - if (error.name !== 'NotFoundError') { - console.warn(`[RESTORE CLEANUP] Could not remove directory ${dirName}:`, error.message); - } - } - } - - // Remove root-level book files - let fileCount = 0; - for (const filename of filesToClean) { - try { - await deviceHandle.removeEntry(filename); - cleanedCount++; - fileCount++; - } catch (error) { - // File might not exist, that's fine - if (error.name !== 'NotFoundError') { - // Only warn for unexpected errors - console.warn(`[RESTORE CLEANUP] Could not remove file ${filename}:`, error.message); - } - } - } - console.log(`[RESTORE CLEANUP] Removed ${fileCount} root files`); - - return cleanedCount; -} diff --git a/src/utils/restore.ts b/src/utils/restore.ts new file mode 100644 index 0000000..d442562 --- /dev/null +++ b/src/utils/restore.ts @@ -0,0 +1,465 @@ +/** + * Restore utilities: parse backup ZIPs and write them back to a Kobo device. + */ + +import { ZipReader, BlobReader, BlobWriter, TextWriter, type Entry, type FileEntry } from '@zip.js/zip.js'; +import { writeFile, writeFileToPath } from './fileSystem.ts'; +import { RestoreError, ERROR_CODES, errorMessage } from './errors.ts'; +import { validateBackupMetadata } from './validation.ts'; +import { openDatabase } from './koboDatabase.ts'; +import type { BackupMetadata, BackupOptions, DeviceInfo } from '../types/kobo.ts'; + +export interface RestoreBookFile { + /** Display name (filename only). */ + name: string; + /** Entry path inside the ZIP, e.g. "books/Author/title.epub". */ + path: string; + /** Destination path relative to the device root. */ + originalPath: string; +} + +export interface ParsedBackup { + metadata: BackupMetadata; + database: ArrayBuffer; + bookFiles: RestoreBookFile[]; + bookPathMap: Map; + file: Blob; + valid: true; +} + +export interface FailedBook { + name: string; + originalPath: string; + error: string; +} + +export interface RestoreVerification { + dbBooksCount: number; + expectedCount: number; + ok: boolean; +} + +export interface RestoreResult { + success: true; + booksRestored: number; + failedBooks: FailedBook[]; + databaseRestored: boolean; + metadata: BackupMetadata; + verification: RestoreVerification | null; +} + +export interface RestoreProgress { + stage: string; + percent: number; + filesProcessed?: number; + totalFiles?: number; +} + +export type RestoreRunOptions = Partial< + Pick +> & { + /** + * Opt-in: recursively removes top-level book folders before restore. Off by + * default because it can delete files added to those folders AFTER the backup. + */ + cleanExistingBooks?: boolean; + onProgress?: (progress: RestoreProgress) => void; +}; + +const isFileEntry = (entry: Entry): entry is FileEntry => !entry.directory; + +/** Parse and validate a backup ZIP file. */ +export async function parseBackupFile(file: Blob): Promise { + const zipReader = new ZipReader(new BlobReader(file)); + try { + const entries = (await zipReader.getEntries()).filter(isFileEntry); + + const metadataEntry = entries.find((e) => e.filename === 'backup-metadata.json'); + const dbEntry = entries.find((e) => e.filename === 'KoboReader.sqlite'); + + if (!metadataEntry) { + throw new RestoreError( + 'Invalid backup: missing backup-metadata.json', + ERROR_CODES.RESTORE_INVALID_FILE, + { + missingFile: 'backup-metadata.json', + }, + ); + } + if (!dbEntry) { + throw new RestoreError('Invalid backup: missing KoboReader.sqlite', ERROR_CODES.RESTORE_INVALID_FILE, { + missingFile: 'KoboReader.sqlite', + }); + } + + const metadata: unknown = JSON.parse(await metadataEntry.getData(new TextWriter())); + const validation = validateBackupMetadata(metadata); + if (!validation.valid) { + throw new RestoreError( + `Invalid backup metadata: ${validation.error}`, + ERROR_CODES.RESTORE_INVALID_FILE, + { + validationError: validation.error, + }, + ); + } + + const database = await (await dbEntry.getData(new BlobWriter())).arrayBuffer(); + const bookPathMap = await extractBookPathsFromDatabase(database); + + const bookFiles: RestoreBookFile[] = entries + .filter((entry) => entry.filename.startsWith('books/')) + .map((entry) => { + // New format: "books/Author/book.epub" (full relative path embedded in the ZIP) + // Old format: "books/book.epub" (filename only, path recovered via pathMap) + const zipRelPath = entry.filename.slice('books/'.length); + const name = zipRelPath.split('/').pop()!; + const originalPath = zipRelPath.includes('/') + ? zipRelPath + : (bookPathMap.get(zipRelPath) ?? zipRelPath); + return { name, path: entry.filename, originalPath }; + }); + + return { metadata: metadata as BackupMetadata, database, bookFiles, bookPathMap, file, valid: true }; + } catch (error) { + if (error instanceof RestoreError) throw error; + throw new RestoreError('Failed to parse backup file', ERROR_CODES.RESTORE_CORRUPTED, { + originalError: error, + }); + } finally { + await zipReader.close(); + } +} + +/** Restore a parsed backup to a Kobo device. */ +export async function restoreToDevice( + deviceHandle: FileSystemDirectoryHandle, + backupData: ParsedBackup, + options: RestoreRunOptions = {}, +): Promise { + const { includeBooks = true, cleanExistingBooks = false, onProgress } = options; + const reportProgress = (stage: string, percent: number, details: Partial = {}) => + onProgress?.({ stage, percent, ...details }); + + try { + reportProgress('Preparing device...', 0); + + let koboFolder: FileSystemDirectoryHandle; + try { + koboFolder = await deviceHandle.getDirectoryHandle('.kobo'); + } catch { + throw new RestoreError('Could not find .kobo folder on device', ERROR_CODES.RESTORE_FAILED, { + reason: 'Invalid Kobo device', + }); + } + + if (includeBooks && cleanExistingBooks && backupData.bookFiles.length > 0) { + reportProgress('Removing existing books...', 5); + try { + await cleanExistingBooksFromDevice(deviceHandle, backupData.bookPathMap); + } catch (error) { + console.warn('[RESTORE] Failed to clean existing books:', error); + } + } + + reportProgress('Restoring database...', 15); + try { + // CRITICAL: delete WAL/SHM files first. If we overwrite the .sqlite but + // leave an old WAL behind, SQLite replays the mismatched WAL and corrupts it. + for (const name of ['KoboReader.sqlite-wal', 'KoboReader.sqlite-shm']) { + await koboFolder.removeEntry(name).catch(() => {}); + } + + reportProgress('Sanitizing database...', 18); + let dbData: ArrayBuffer | Uint8Array = backupData.database; + try { + const db = await openDatabase(backupData.database); + try { + db.sanitize(); + dbData = db.export(); + } finally { + db.close(); + } + } catch (error) { + console.warn('[RESTORE] Database sanitization failed, using original:', error); + } + + await writeFile(koboFolder, 'KoboReader.sqlite', dbData); + } catch (error) { + throw new RestoreError('Failed to write database to device', ERROR_CODES.RESTORE_FAILED, { + originalError: error, + }); + } + reportProgress('Database restored', 20); + + const failedBooks: FailedBook[] = []; + if (includeBooks && backupData.bookFiles.length > 0) { + const totalBooks = backupData.bookFiles.length; + const zipReader = new ZipReader(new BlobReader(backupData.file)); + try { + const entries = new Map( + (await zipReader.getEntries()).filter(isFileEntry).map((entry) => [entry.filename, entry]), + ); + + for (const [i, bookFile] of backupData.bookFiles.entries()) { + const zipEntry = entries.get(bookFile.path); + if (!zipEntry) { + failedBooks.push({ + name: bookFile.name, + originalPath: bookFile.originalPath, + error: 'File not found in ZIP archive', + }); + continue; + } + + // Write to the original path: Kobo matches files to DB rows via + // ContentID (file:///mnt/onboard/). A wrong path makes + // the Kobo create a new empty record, losing progress and annotations. + let lastError: unknown = null; + for (let attempt = 1; attempt <= 2; attempt++) { + try { + await writeFileToPath( + deviceHandle, + bookFile.originalPath, + await zipEntry.getData(new BlobWriter()), + ); + lastError = null; + break; + } catch (error) { + lastError = error; + if (attempt < 2) await new Promise((resolve) => setTimeout(resolve, 300)); + } + } + if (lastError) { + console.error(`Failed to restore book after retries: ${bookFile.name}`, lastError); + failedBooks.push({ + name: bookFile.name, + originalPath: bookFile.originalPath, + error: errorMessage(lastError), + }); + } + + reportProgress(`Restoring books (${i + 1}/${totalBooks})...`, 20 + ((i + 1) / totalBooks) * 65, { + filesProcessed: i + 1, + totalFiles: totalBooks, + }); + } + } finally { + await zipReader.close(); + } + reportProgress('Books restored', 85); + } + + // Post-restore validation: reopen the database from the device and count books + reportProgress('Verifying restore...', 90); + let verification: RestoreVerification | null = null; + try { + const restored = await ( + await (await koboFolder.getFileHandle('KoboReader.sqlite')).getFile() + ).arrayBuffer(); + const verifyDb = await openDatabase(restored); + const dbBooksCount = verifyDb.getBooks().length; + verifyDb.close(); + const expectedCount = backupData.metadata?.statistics?.totalBooks || 0; + verification = { + dbBooksCount, + expectedCount, + ok: expectedCount === 0 || dbBooksCount >= expectedCount, + }; + } catch (verifyError) { + console.warn('[RESTORE] Post-restore verification failed:', verifyError); + } + + reportProgress('Restore complete', 100); + + return { + success: true, + booksRestored: includeBooks ? backupData.bookFiles.length - failedBooks.length : 0, + failedBooks, + databaseRestored: true, + metadata: backupData.metadata, + verification, + }; + } catch (error) { + if (error instanceof RestoreError) throw error; + throw new RestoreError('Failed to restore backup to device', ERROR_CODES.RESTORE_FAILED, { + originalError: error, + }); + } +} + +export interface CompatibilityResult { + compatible: boolean; + warnings: string[]; +} + +/** Compare backup metadata with the target device. */ +export function checkCompatibility( + backupMetadata: BackupMetadata, + deviceInfo: Partial | null, +): CompatibilityResult { + const warnings: string[] = []; + const backupModel = backupMetadata.device?.model; + const backupFw = backupMetadata.device?.firmwareVersion; + + if (backupModel && deviceInfo?.model && backupModel !== deviceInfo.model) { + warnings.push( + `Backup is from a different device model (${backupModel} → ${deviceInfo.model}). Settings may not transfer correctly.`, + ); + } + if (backupFw && deviceInfo?.firmwareVersion && backupFw !== deviceInfo.firmwareVersion) { + warnings.push( + `Different firmware versions (${backupFw} → ${deviceInfo.firmwareVersion}). This should work but may have minor issues.`, + ); + } + if (backupMetadata.created) { + const days = (Date.now() - new Date(backupMetadata.created).getTime()) / 86_400_000; + if (days > 180) { + warnings.push( + `This backup is ${Math.floor(days)} days old. Your Kobo may have received firmware updates since then.`, + ); + } + } + + return { compatible: true, warnings }; +} + +export interface BackupPreviewData { + created: string; + deviceModel: string; + firmwareVersion: string; + statistics: { + totalBooks: number; + totalAnnotations: number; + booksStarted: number; + booksFinished: number; + totalReadingTime: number; + }; + contents: { + databaseIncluded: boolean; + booksIncluded: boolean; + bookCount: number; + annotationsIncluded: boolean; + }; + options: Partial; +} + +export function previewBackup({ + metadata, + bookFiles, +}: Pick): BackupPreviewData { + const s = metadata.statistics; + return { + created: metadata.created, + deviceModel: metadata.device?.model || 'Unknown', + firmwareVersion: metadata.device?.firmwareVersion || 'Unknown', + statistics: { + totalBooks: s?.totalBooks || 0, + totalAnnotations: s?.totalAnnotations || 0, + booksStarted: s?.booksStarted || 0, + booksFinished: s?.booksFinished || 0, + totalReadingTime: s?.totalReadingTime || 0, + }, + contents: { + databaseIncluded: true, + booksIncluded: bookFiles.length > 0, + bookCount: bookFiles.length, + annotationsIncluded: metadata.options?.includeAnnotations || false, + }, + options: metadata.options || {}, + }; +} + +export type TargetValidation = { valid: true } | { valid: false; error: string }; + +/** Check the restore target is a Kobo and writable. */ +export async function validateRestoreTarget( + deviceHandle: FileSystemDirectoryHandle, +): Promise { + try { + await deviceHandle.getDirectoryHandle('.kobo'); + } catch { + return { valid: false, error: 'Not a valid Kobo device (missing .kobo folder)' }; + } + try { + await deviceHandle.getFileHandle('_test_write.tmp', { create: true }); + await deviceHandle.removeEntry('_test_write.tmp'); + } catch { + return { valid: false, error: 'Cannot write to device (check permissions)' }; + } + return { valid: true }; +} + +/** Kobo ContentID ("file:///mnt/onboard/a/b.epub") → device-relative path ("a/b.epub"). */ +export function contentIdToRelativePath(contentId: string): string { + let path = contentId.replace(/^file:\/\//, '').replace(/^\/mnt\/onboard\//, ''); + // ContentIDs may be URL-encoded (%20 etc.); decoding is a no-op for plain paths. + try { + path = decodeURIComponent(path); + } catch { + // keep as-is if malformed + } + return path; +} + +/** Map filename → original relative path, read from the backup database. */ +async function extractBookPathsFromDatabase(databaseBuffer: ArrayBuffer): Promise> { + const pathMap = new Map(); + try { + const db = await openDatabase(databaseBuffer); + const books = db.getBooks(); + db.close(); + + for (const book of books) { + const originalPath = contentIdToRelativePath(book.ContentID); + const filename = originalPath.split('/').pop(); + // First occurrence wins for duplicate filenames in different folders. + if (filename && !pathMap.has(filename)) pathMap.set(filename, originalPath); + } + } catch (error) { + console.error('[RESTORE] Failed to extract book paths from database:', error); + } + return pathMap; +} + +/** Never recursively delete these top-level entries. */ +const PROTECTED_DIRS = new Set([ + '', + '.', + '..', + '.kobo', + '.adobe-digital-editions', + 'System Volume Information', +]); + +/** Remove book folders/files that the restore would overwrite (opt-in). */ +async function cleanExistingBooksFromDevice( + deviceHandle: FileSystemDirectoryHandle, + bookPathMap: Map, +): Promise { + const dirsToClean = new Set(); + const filesToClean = new Set(); + + for (const [filename, originalPath] of bookPathMap) { + const [topLevel, ...rest] = originalPath.split('/'); + if (rest.length > 0 && topLevel && !PROTECTED_DIRS.has(topLevel)) dirsToClean.add(topLevel); + // Also remove flat copies in the root left by older buggy restores. + filesToClean.add(filename); + } + + let cleanedCount = 0; + const remove = async (name: string, recursive: boolean) => { + try { + await deviceHandle.removeEntry(name, { recursive }); + cleanedCount++; + } catch (error) { + if ((error as { name?: string } | null)?.name !== 'NotFoundError') { + console.warn(`[RESTORE CLEANUP] Could not remove ${name}:`, errorMessage(error)); + } + } + }; + + for (const dirName of dirsToClean) await remove(dirName, true); + for (const filename of filesToClean) await remove(filename, false); + return cleanedCount; +} diff --git a/src/utils/validation.js b/src/utils/validation.js deleted file mode 100644 index dddfd6f..0000000 --- a/src/utils/validation.js +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Validation utilities for Kobo Backup Manager - */ - -/** - * Check if a directory handle represents a valid Kobo device - * @param {FileSystemDirectoryHandle} dirHandle - Directory handle to validate - * @returns {Promise} True if valid Kobo directory - */ -export async function isValidKoboDirectory(dirHandle) { - if (!dirHandle) return false; - - try { - // Check for .kobo folder - let koboFolder = null; - - for await (const entry of dirHandle.values()) { - if (entry.name === '.kobo' && entry.kind === 'directory') { - koboFolder = entry; - break; - } - } - - if (!koboFolder) { - return false; - } - - // Check for KoboReader.sqlite inside .kobo folder - for await (const entry of koboFolder.values()) { - if (entry.name === 'KoboReader.sqlite' && entry.kind === 'file') { - return true; - } - } - - return false; - } catch (error) { - console.error('Error validating Kobo directory:', error); - return false; - } -} - -/** - * Validate a backup ZIP file - * @param {File} file - ZIP file to validate - * @returns {Promise<{valid: boolean, error?: string}>} Validation result - */ -export async function validateBackupFile(file) { - // Check file exists - if (!file) { - return { valid: false, error: 'No file provided' }; - } - - // Check file type - if (!file.name.endsWith('.zip')) { - return { valid: false, error: 'File must be a ZIP archive' }; - } - - // Check file size (max 10GB) - const maxSize = 10 * 1024 * 1024 * 1024; // 10GB - if (file.size > maxSize) { - return { valid: false, error: 'Backup file is too large (max 10GB)' }; - } - - // Check minimum size (at least 1KB) - if (file.size < 1024) { - return { valid: false, error: 'Backup file is too small to be valid' }; - } - - return { valid: true }; -} - -/** - * Validate backup metadata - * @param {object} metadata - Metadata object to validate - * @returns {{valid: boolean, error?: string}} Validation result - */ -export function validateBackupMetadata(metadata) { - if (!metadata) { - return { valid: false, error: 'No metadata provided' }; - } - - // Check required fields - const requiredFields = ['version', 'created', 'statistics']; - for (const field of requiredFields) { - if (!(field in metadata)) { - return { valid: false, error: `Missing required field: ${field}` }; - } - } - - // Check version format - if (typeof metadata.version !== 'string') { - return { valid: false, error: 'Invalid version format' }; - } - - // Check created date - const createdDate = new Date(metadata.created); - if (isNaN(createdDate.getTime())) { - return { valid: false, error: 'Invalid creation date' }; - } - - // Check statistics - if (typeof metadata.statistics !== 'object') { - return { valid: false, error: 'Invalid statistics format' }; - } - - return { valid: true }; -} - -/** - * Validate file extension - * @param {string} filename - Filename to check - * @param {string[]} validExtensions - Array of valid extensions (e.g., ['.epub', '.pdf']) - * @returns {boolean} True if file has valid extension - */ -export function hasValidExtension(filename, validExtensions) { - if (!filename) return false; - - const lowerFilename = filename.toLowerCase(); - return validExtensions.some(ext => lowerFilename.endsWith(ext.toLowerCase())); -} - -/** - * Validate Kobo book file - * @param {string} filename - Filename to validate - * @returns {boolean} True if valid book file - */ -export function isValidBookFile(filename) { - const validExtensions = ['.epub', '.kepub.epub', '.pdf', '.mobi', '.txt', '.cbz', '.cbr']; - return hasValidExtension(filename, validExtensions); -} diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 0000000..be5d13d --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,56 @@ +/** + * Validation utilities. + */ + +import type { BackupMetadata } from '../types/kobo.ts'; + +export type ValidationResult = { valid: true } | { valid: false; error: string }; + +/** A valid Kobo root contains `.kobo/KoboReader.sqlite`. */ +export async function isValidKoboDirectory(dirHandle: FileSystemDirectoryHandle | null): Promise { + if (!dirHandle) return false; + try { + const koboFolder = await dirHandle.getDirectoryHandle('.kobo'); + await koboFolder.getFileHandle('KoboReader.sqlite'); + return true; + } catch { + return false; + } +} + +const MAX_BACKUP_SIZE = 10 * 1024 * 1024 * 1024; // 10 GB + +export function validateBackupFile(file: File | null | undefined): ValidationResult { + if (!file) return { valid: false, error: 'No file provided' }; + if (!file.name.toLowerCase().endsWith('.zip')) return { valid: false, error: 'File must be a ZIP archive' }; + if (file.size > MAX_BACKUP_SIZE) return { valid: false, error: 'Backup file is too large (max 10GB)' }; + if (file.size < 1024) return { valid: false, error: 'Backup file is too small to be valid' }; + return { valid: true }; +} + +export function validateBackupMetadata(metadata: unknown): ValidationResult { + if (!metadata || typeof metadata !== 'object') return { valid: false, error: 'No metadata provided' }; + + const m = metadata as Partial; + for (const field of ['version', 'created', 'statistics'] as const) { + if (!(field in m)) return { valid: false, error: `Missing required field: ${field}` }; + } + if (typeof m.version !== 'string') return { valid: false, error: 'Invalid version format' }; + if (isNaN(new Date(m.created as string).getTime())) return { valid: false, error: 'Invalid creation date' }; + if (!m.statistics || typeof m.statistics !== 'object') + return { valid: false, error: 'Invalid statistics format' }; + + return { valid: true }; +} + +export function hasValidExtension(filename: string | null | undefined, validExtensions: string[]): boolean { + if (!filename) return false; + const lower = filename.toLowerCase(); + return validExtensions.some((ext) => lower.endsWith(ext.toLowerCase())); +} + +const BOOK_EXTENSIONS = ['.epub', '.kepub.epub', '.pdf', '.mobi', '.txt', '.cbz', '.cbr']; + +export function isValidBookFile(filename: string): boolean { + return hasValidExtension(filename, BOOK_EXTENSIONS); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..f97739d --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,4 @@ +/// + +/** App version from package.json, injected at build time. */ +declare const __APP_VERSION__: string; diff --git a/tailwind.config.js b/tailwind.config.js deleted file mode 100644 index 731857e..0000000 --- a/tailwind.config.js +++ /dev/null @@ -1,87 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: { - colors: { - // Kobo custom color palette - 'kobo-cream': '#F5F1E8', - 'kobo-cream-dark': '#E8E3D8', - 'kobo-accent': '#D4A574', - 'kobo-accent-dark': '#B8895E', - - 'kobo-dark': '#2C2416', - 'kobo-gray': '#6B675E', - 'kobo-gray-light': '#A8A49A', - - 'kobo-success': '#7FB069', - 'kobo-warning': '#F4A261', - 'kobo-error': '#E63946', - 'kobo-info': '#457B9D', - - 'kobo-overlay': 'rgba(44, 36, 22, 0.4)', - 'kobo-glass': 'rgba(245, 241, 232, 0.8)', - }, - fontFamily: { - 'display': ['Oswald', 'system-ui', 'sans-serif'], - 'body': ['Poppins', 'system-ui', 'sans-serif'], - }, - fontSize: { - // Professional type scale - readable sizes - '6xl': ['4rem', { lineHeight: '1' }], // 64px - hero titles - '5xl': ['3rem', { lineHeight: '1' }], // 48px - page titles - '4xl': ['2.25rem', { lineHeight: '1.1' }], // 36px - section headers - '3xl': ['1.875rem', { lineHeight: '1.2' }], // 30px - subsection headers - '2xl': ['1.5rem', { lineHeight: '1.3' }], // 24px - card titles - 'xl': ['1.25rem', { lineHeight: '1.4' }], // 20px - large text - 'lg': ['1.125rem', { lineHeight: '1.5' }], // 18px - emphasis - 'base': ['1rem', { lineHeight: '1.5' }], // 16px - body text (DEFAULT) - 'sm': ['0.875rem', { lineHeight: '1.5' }], // 14px - small text - 'xs': ['0.75rem', { lineHeight: '1.5' }], // 12px - captions only - }, - animation: { - 'fade-in': 'fadeIn 0.5s ease-out', - 'slide-up': 'slideUp 0.3s ease-out', - 'shimmer': 'shimmer 2s infinite', - 'pulse': 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite', - 'bounce': 'bounce 1s ease-in-out 3', - }, - keyframes: { - fadeIn: { - 'from': { opacity: '0' }, - 'to': { opacity: '1' }, - }, - slideUp: { - 'from': { - opacity: '0', - transform: 'translateY(20px)', - }, - 'to': { - opacity: '1', - transform: 'translateY(0)', - }, - }, - shimmer: { - '0%': { transform: 'translateX(-100%)' }, - '100%': { transform: 'translateX(100%)' }, - }, - pulse: { - '0%, 100%': { opacity: '1' }, - '50%': { opacity: '0.5' }, - }, - bounce: { - '0%, 100%': { transform: 'translateY(0)' }, - '50%': { transform: 'translateY(-10px)' }, - }, - }, - boxShadow: { - 'elevated': '0 8px 30px rgb(0 0 0 / 0.06)', - 'elevated-hover': '0 12px 40px rgb(0 0 0 / 0.08)', - }, - }, - }, - plugins: [], -} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..8bf58b2 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["vite/client", "wicg-file-system-access"], + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "skipLibCheck": true, + "allowJs": true, + "checkJs": false, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + "erasableSyntaxOnly": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4a3e749 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.test.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..bae3157 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "bundler", + "types": ["node"], + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "skipLibCheck": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "erasableSyntaxOnly": true + }, + "include": ["vite.config.ts", "scripts"] +} diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..fbc8408 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.test.tsbuildinfo", + "types": ["vite/client", "wicg-file-system-access", "node"] + }, + "include": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test", "src/vite-env.d.ts"] +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index 1349644..0000000 --- a/vite.config.js +++ /dev/null @@ -1,31 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -// https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react()], - build: { - target: 'es2020', - rollupOptions: { - output: { - manualChunks: { - 'vendor': ['react', 'react-dom'], - 'db': ['sql.js'], // SQLite WASM - large file, separate chunk - 'zip': ['client-zip', '@zip.js/zip.js'], // streaming ZIP read/write - } - } - }, - // Optimize chunk size - chunkSizeWarningLimit: 1000, - }, - optimizeDeps: { - // Don't exclude sql.js - let Vite handle it - }, - server: { - headers: { - // Enable SharedArrayBuffer for sql.js WASM - 'Cross-Origin-Embedder-Policy': 'require-corp', - 'Cross-Origin-Opener-Policy': 'same-origin', - }, - }, -}) diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..b781078 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,35 @@ +/// +import { readFileSync } from 'node:fs'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + version: string; +}; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), tailwindcss()], + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, + build: { + target: 'es2022', + rolldownOptions: { + output: { + codeSplitting: { + groups: [ + { name: 'vendor', test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/ }, + { name: 'zip', test: /node_modules[\\/](client-zip|@zip\.js)[\\/]/ }, + ], + }, + }, + }, + }, + test: { + environment: 'node', + include: ['src/**/*.test.{ts,tsx,js,jsx}'], + testTimeout: 30_000, + }, +}); From 8512a4cbc03e3fe2f6b5ba462e030ea1152aea02 Mon Sep 17 00:00:00 2001 From: Fanfulla <224395883+Fanfulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:23:08 +0200 Subject: [PATCH 3/8] fix: correct reading-time units, schema version, sanitize and device identity - Kobo stores TimeSpentReading in seconds: values are now converted to minutes (stats were inflated 60x, e.g. 1418h shown instead of 23h38m) - sanitize(): fix "PRAGMA journaling_mode" typo (silently ignored) so the restored database really leaves WAL mode - schema version read from the DbVersion table (PRAGMA user_version is always 0 on Kobo databases) - real model/firmware from .kobo/version (serial number is discarded), with the Nickel model id table - compatibility check returns coded warnings and blocks restoring a newer schema onto older firmware - restore preview computes stats from the backup database (old metadata stored seconds labelled as minutes) - scanning extracted to utils/scan.ts; warns about a pending (non-empty) WAL file, recorded in backup metadata - stats query shares the exact same filter as the book list Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/components/backup/ScanningScreen.jsx | 9 +- src/components/restore/BackupPreview.jsx | 2 +- src/hooks/useKoboDevice.ts | 41 +------- src/types/kobo.ts | 13 +++ src/utils/backup.ts | 1 + src/utils/koboDatabase.test.ts | 67 +++++++++++++ src/utils/koboDatabase.ts | 39 +++++--- src/utils/koboDevice.test.ts | 38 ++++++++ src/utils/koboDevice.ts | 73 +++++++++++++++ src/utils/restore.test.ts | 63 +++++++++++++ src/utils/restore.ts | 114 +++++++++++++++++------ src/utils/scan.test.ts | 65 +++++++++++++ src/utils/scan.ts | 70 ++++++++++++++ 13 files changed, 514 insertions(+), 81 deletions(-) create mode 100644 src/utils/koboDatabase.test.ts create mode 100644 src/utils/koboDevice.test.ts create mode 100644 src/utils/koboDevice.ts create mode 100644 src/utils/restore.test.ts create mode 100644 src/utils/scan.test.ts create mode 100644 src/utils/scan.ts diff --git a/src/components/backup/ScanningScreen.jsx b/src/components/backup/ScanningScreen.jsx index 660691e..1c6b6ea 100644 --- a/src/components/backup/ScanningScreen.jsx +++ b/src/components/backup/ScanningScreen.jsx @@ -7,7 +7,14 @@ import { ProgressBar } from '../common/ProgressBar.tsx'; import { Icon } from '../common/Icon.tsx'; export function ScanningScreen({ scanProgress }) { - const { stage, current, total } = scanProgress; + const { current, total } = scanProgress; + const stage = [ + 'Preparing...', + 'Reading database...', + 'Analyzing books...', + 'Finding book files...', + 'Scan complete', + ][current]; const percent = total > 0 ? (current / total) * 100 : 0; diff --git a/src/components/restore/BackupPreview.jsx b/src/components/restore/BackupPreview.jsx index 3426d4b..01fdc12 100644 --- a/src/components/restore/BackupPreview.jsx +++ b/src/components/restore/BackupPreview.jsx @@ -65,7 +65,7 @@ export function BackupPreview({ preview, compatibility, onRestore, onCancel }) { >
- {warning} + {warning.message}
))} diff --git a/src/hooks/useKoboDevice.ts b/src/hooks/useKoboDevice.ts index 4667b74..7c0ad7f 100644 --- a/src/hooks/useKoboDevice.ts +++ b/src/hooks/useKoboDevice.ts @@ -1,17 +1,15 @@ import { useState, useCallback } from 'react'; -import { getFileByPath, readFile, getAllFiles } from '../utils/fileSystem.ts'; -import { extractAllData } from '../utils/koboDatabase.ts'; -import { isValidBookFile } from '../utils/validation.ts'; +import { scanKoboDevice, SCAN_STEPS } from '../utils/scan.ts'; import { errorCode, errorMessage } from '../utils/errors.ts'; -import type { BookFileEntry, ScanResult, UiError } from '../types/kobo.ts'; +import type { ScanResult, UiError } from '../types/kobo.ts'; export interface ScanProgress { - stage: string; + /** 1 = reading database, 2 = analyzing books, 3 = finding files, 4 = done. */ current: number; total: number; } -const IDLE: ScanProgress = { stage: '', current: 0, total: 0 }; +const IDLE: ScanProgress = { current: 0, total: SCAN_STEPS }; /** Scan a Kobo device: database + book files. */ export function useKoboDevice() { @@ -22,37 +20,8 @@ export function useKoboDevice() { const scanDevice = useCallback(async (dirHandle: FileSystemDirectoryHandle): Promise => { setIsScanning(true); setError(null); - const step = (current: number, stage: string) => setScanProgress({ stage, current, total: 4 }); - try { - step(1, 'Reading database...'); - const database = await readFile(await getFileByPath(dirHandle, '.kobo/KoboReader.sqlite')); - - step(2, 'Analyzing books...'); - const extracted = await extractAllData(database); - - step(3, 'Finding book files...'); - const candidates = (await getAllFiles(dirHandle)).filter((file) => isValidBookFile(file.name)); - // File size only (metadata, no content read) so the size estimate is accurate. - const bookFiles: BookFileEntry[] = await Promise.all( - candidates.map(async (file) => { - try { - return { ...file, size: (await file.handle.getFile()).size }; - } catch { - return { ...file, size: 0 }; - } - }), - ); - - step(4, 'Scan complete'); - return { - books: extracted.books, - annotations: extracted.annotations, - stats: extracted.stats, - deviceInfo: extracted.deviceInfo, - bookFiles, - database, - }; + return await scanKoboDevice(dirHandle, (current) => setScanProgress({ current, total: SCAN_STEPS })); } catch (err) { setError({ title: 'Scan Failed', diff --git a/src/types/kobo.ts b/src/types/kobo.ts index d82da30..c4ec760 100644 --- a/src/types/kobo.ts +++ b/src/types/kobo.ts @@ -50,8 +50,11 @@ export interface ReadingStats { export interface DeviceInfo { model: string; + /** Numeric Kobo model id from `.kobo/version` (e.g. "388"). */ + modelId?: string; firmwareVersion: string; databaseVersion: string; + /** Kobo DbVersion (schema) number. */ schemaVersion: number; } @@ -82,13 +85,21 @@ export interface BookFileEntry { size: number; } +/** + * - `wal-pending`: a non-empty KoboReader.sqlite-wal exists, i.e. the most + * recent changes are not yet in the main database file. + */ +export type ScanWarning = 'wal-pending'; + export interface ScanResult { books: KoboBook[]; annotations: KoboAnnotation[]; stats: ReadingStats; + collections: KoboCollection[]; deviceInfo: DeviceInfo; bookFiles: BookFileEntry[]; database: ArrayBuffer; + warnings: ScanWarning[]; } export interface BackupOptions { @@ -121,6 +132,8 @@ export interface BackupMetadata { filesChecked: number; fileChecksums: Record; errors: { file: string; error: string }[]; + /** Conditions detected while scanning the device (see ScanWarning). */ + warnings?: ScanWarning[]; }; compatibility?: { minAppVersion: string; supportedDevices: string[] }; } diff --git a/src/utils/backup.ts b/src/utils/backup.ts index cba311e..bb14d0f 100644 --- a/src/utils/backup.ts +++ b/src/utils/backup.ts @@ -75,6 +75,7 @@ function buildMetadata( // Shared mutable array filled by the generator; JSON.stringify captures // the final state when the metadata entry is yielded (after all books). errors, + warnings: koboData.warnings ?? [], }, compatibility: { minAppVersion: '1.0.0', supportedDevices: ['all'] }, }; diff --git a/src/utils/koboDatabase.test.ts b/src/utils/koboDatabase.test.ts new file mode 100644 index 0000000..29b16fe --- /dev/null +++ b/src/utils/koboDatabase.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { buildKoboDb, fixture } from '../test/builders.ts'; + +vi.mock('sql.js', async (importOriginal) => + (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), +); + +const { openDatabase, extractAllData } = await import('./koboDatabase.ts'); +const initSqlJs = (await import('sql.js')).default; + +describe('koboDatabase — reading time units', () => { + it('converts Kobo TimeSpentReading (seconds) to minutes per book and in stats', async () => { + const bytes = await buildKoboDb([ + { contentId: 'file:///mnt/onboard/a.epub', title: 'A', time: 31207, percent: 100 }, + { contentId: 'file:///mnt/onboard/b.epub', title: 'B', time: 1551, percent: 1 }, + ]); + const data = await extractAllData(bytes.buffer); + const a = data.books.find((b) => b.Title === 'A')!; + expect(a.TimeSpentReading).toBe(520); // 31207 s ≈ 8h 40m, not 520 hours + expect(data.stats.totalMinutesRead).toBe(Math.round((31207 + 1551) / 60)); + }); + + const REAL_DB = fixture('KoboReader.sqlite'); + it.skipIf(!REAL_DB)('real device database: total minutes equals raw seconds / 60', async () => { + const buf = readFileSync(REAL_DB!); + const SQL = await initSqlJs(); + const raw = new SQL.Database(buf); + const rawSeconds = Number( + raw.exec(`SELECT SUM(TimeSpentReading) FROM content WHERE ContentType = 6 AND ContentID LIKE 'file://%' + AND LOWER(IsDownloaded) = 'true' AND (BookTitle IS NULL OR BookTitle = '')`)[0]! + .values[0]![0], + ); + raw.close(); + const data = await extractAllData(new Uint8Array(buf).buffer); + expect(data.stats.totalMinutesRead).toBe(Math.round(rawSeconds / 60)); + expect(data.stats.totalBooks).toBe(data.books.length); + }); +}); + +describe('koboDatabase — schema version', () => { + it('reads the Kobo schema version from the DbVersion table', async () => { + const db = await openDatabase(await buildKoboDb([], [], { dbVersion: 176 })); + expect(db.getSchemaVersion()).toBe(176); + db.close(); + }); + + it('falls back to 0 when DbVersion is missing', async () => { + const db = await openDatabase(await buildKoboDb()); + expect(db.getSchemaVersion()).toBe(0); + db.close(); + }); +}); + +describe('koboDatabase — sanitize()', () => { + it('switches a WAL-mode database file to rollback journal mode', async () => { + const bytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/a.epub', title: 'A' }]); + // Bytes 18/19 of the SQLite header are the file format write/read versions: 2 = WAL. + bytes[18] = 2; + bytes[19] = 2; + const db = await openDatabase(bytes); + db.sanitize(); + const out = db.export(); + db.close(); + expect([out[18], out[19]]).toEqual([1, 1]); + }); +}); diff --git a/src/utils/koboDatabase.ts b/src/utils/koboDatabase.ts index 16dacf9..4a1fd1a 100644 --- a/src/utils/koboDatabase.ts +++ b/src/utils/koboDatabase.ts @@ -39,6 +39,16 @@ function toDate(value: SqlValue): Date | null { const str = (value: SqlValue): string | null => value === null || value === undefined ? null : String(value); const num = (value: SqlValue): number => (typeof value === 'number' ? value : Number(value) || 0); +/** Kobo stores TimeSpentReading in SECONDS; the app works in minutes. */ +const secondsToMinutes = (value: SqlValue): number => Math.round(num(value) / 60); + +/** Sideloaded books: downloaded, top-level (not chapters), stored on the device (file://). */ +const SIDELOADED_BOOKS_WHERE = ` + ContentType = 6 + AND LOWER(IsDownloaded) = 'true' + AND (BookTitle IS NULL OR BookTitle = '') + AND ContentID LIKE 'file://%' +`; export class KoboDatabase { private readonly db: Database; @@ -86,10 +96,7 @@ export class KoboDatabase { ISBN, Language, ___PercentRead AS Progress, ReadStatus, DateCreated, DateLastRead, ContentID AS FilePath, ImageId AS CoverId, TimeSpentReading, MimeType FROM content - WHERE ContentType = 6 - AND LOWER(IsDownloaded) = 'true' - AND (BookTitle IS NULL OR BookTitle = '') - AND ContentID LIKE 'file://%' + WHERE ${SIDELOADED_BOOKS_WHERE} ORDER BY DateLastRead DESC `, ).map((row) => ({ @@ -108,7 +115,7 @@ export class KoboDatabase { DateLastRead: toDate(row.DateLastRead), FilePath: row.FilePath ? safeDecode(String(row.FilePath).replace('file://', '')) : null, CoverId: str(row.CoverId), - TimeSpentReading: num(row.TimeSpentReading), + TimeSpentReading: secondsToMinutes(row.TimeSpentReading), MimeType: str(row.MimeType), })), ); @@ -155,14 +162,11 @@ export class KoboDatabase { SUM(CASE WHEN ___PercentRead > 0 THEN 1 ELSE 0 END) AS BooksStarted, SUM(CASE WHEN ___PercentRead >= 100 THEN 1 ELSE 0 END) AS BooksFinished, SUM(CASE WHEN ReadStatus = 1 THEN 1 ELSE 0 END) AS CurrentlyReading, - SUM(TimeSpentReading) AS TotalMinutesRead, + SUM(TimeSpentReading) AS TotalSecondsRead, AVG(___PercentRead) AS AverageProgress, COUNT(DISTINCT Attribution) AS UniqueAuthors FROM content - WHERE ContentType = 6 - AND BookTitle IS NULL - AND IsDownloaded = 'true' - AND ContentID LIKE 'file://%' + WHERE ${SIDELOADED_BOOKS_WHERE} `)[0] ?? {}; return { @@ -170,7 +174,7 @@ export class KoboDatabase { booksStarted: num(stats.BooksStarted ?? 0), booksFinished: num(stats.BooksFinished ?? 0), currentlyReading: num(stats.CurrentlyReading ?? 0), - totalMinutesRead: num(stats.TotalMinutesRead ?? 0), + totalMinutesRead: secondsToMinutes(stats.TotalSecondsRead ?? 0), averageProgress: stats.AverageProgress ? Math.round(num(stats.AverageProgress)) : 0, uniqueAuthors: num(stats.UniqueAuthors ?? 0), }; @@ -201,7 +205,17 @@ export class KoboDatabase { } } + /** + * Kobo schema version. Nickel keeps it in the DbVersion table (PRAGMA + * user_version is always 0 on Kobo databases). + */ getSchemaVersion(): number { + try { + const version = this.rows('SELECT version FROM DbVersion LIMIT 1')[0]?.version; + if (version !== undefined && version !== null) return num(version); + } catch { + // Older/synthetic databases without DbVersion + } try { return num(this.rows('PRAGMA user_version')[0]?.user_version ?? 0); } catch { @@ -239,7 +253,8 @@ export class KoboDatabase { try { const integrity = this.db.exec('PRAGMA integrity_check'); console.log('[DB] Integrity check result:', integrity[0]?.values[0]?.[0]); - this.db.exec('PRAGMA journaling_mode = DELETE'); + // Rollback journal (not WAL): the file is self-contained once written to the device. + this.db.exec('PRAGMA journal_mode = DELETE'); this.db.exec('VACUUM'); } catch (error) { throw new DatabaseError('Failed to sanitize database', ERROR_CODES.DB_WRITE_ERROR, { diff --git a/src/utils/koboDevice.test.ts b/src/utils/koboDevice.test.ts new file mode 100644 index 0000000..c78baa5 --- /dev/null +++ b/src/utils/koboDevice.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { parseKoboVersionFile, readDeviceVersion, modelNameForId } from './koboDevice.ts'; +import { MemDirHandle, freshDevice } from '../test/memoryFs.ts'; + +describe('koboDevice — .kobo/version parsing', () => { + it('extracts firmware and model, never the serial number', () => { + const info = parseKoboVersionFile( + 'N418XXXXXXXXX,4.1.15,4.38.21908,4.1.15,4.1.15,00000000-0000-0000-0000-000000000390\n', + ); + expect(info).toEqual({ firmwareVersion: '4.38.21908', modelId: '390', model: 'Kobo Libra Colour' }); + expect(JSON.stringify(info)).not.toContain('N418'); + }); + + it('handles unknown model ids gracefully', () => { + expect(modelNameForId('999')).toBe('Kobo (model 999)'); + }); + + it('returns null for malformed content', () => { + expect(parseKoboVersionFile('garbage')).toBeNull(); + expect(parseKoboVersionFile('')).toBeNull(); + }); + + it('reads the version file from a device handle', async () => { + const dev = await freshDevice(); + await dev.writeText( + '.kobo/version', + 'SN,4.1.15,4.41.23145,4.1.15,4.1.15,00000000-0000-0000-0000-000000000388', + ); + expect(await readDeviceVersion(dev.handle)).toMatchObject({ + model: 'Kobo Libra 2', + firmwareVersion: '4.41.23145', + }); + }); + + it('returns null when the version file is missing', async () => { + expect(await readDeviceVersion(new MemDirHandle().handle)).toBeNull(); + }); +}); diff --git a/src/utils/koboDevice.ts b/src/utils/koboDevice.ts new file mode 100644 index 0000000..13039df --- /dev/null +++ b/src/utils/koboDevice.ts @@ -0,0 +1,73 @@ +/** + * Device identification from `.kobo/version`. + * + * The file is a single comma-separated line: + * ,,,,,00000000-0000-0000-0000-000000000 + * The serial number is personal data: it is parsed out and never kept. + */ + +/** + * Model ids as shipped in Nickel (extracted from libnickel by pgaskin/koboutils + * kobo-nickeldev). Unknown ids fall back to "Kobo (model N)". + */ +const KOBO_MODELS: Record = { + '310': 'Kobo Touch', + '320': 'Kobo Touch', + '330': 'Kobo Glo', + '340': 'Kobo Mini', + '350': 'Kobo Aura HD', + '360': 'Kobo Aura', + '370': 'Kobo Aura H2O', + '371': 'Kobo Glo HD', + '372': 'Kobo Touch 2.0', + '373': 'Kobo Aura ONE', + '374': 'Kobo Aura H2O Edition 2', + '375': 'Kobo Aura Edition 2', + '376': 'Kobo Clara HD', + '377': 'Kobo Forma', + '378': 'Kobo Aura H2O Edition 2', + '379': 'Kobo Aura Edition 2', + '380': 'Kobo Forma', + '381': 'Kobo Aura ONE', + '382': 'Kobo Nia', + '383': 'Kobo Sage', + '384': 'Kobo Libra H2O', + '386': 'Kobo Clara 2E', + '387': 'Kobo Elipsa', + '388': 'Kobo Libra 2', + '389': 'Kobo Elipsa 2E', + '390': 'Kobo Libra Colour', + '391': 'Kobo Clara BW', + '393': 'Kobo Clara Colour', +}; + +export interface KoboVersionInfo { + firmwareVersion: string; + modelId: string; + model: string; +} + +export function modelNameForId(modelId: string): string { + return KOBO_MODELS[modelId] ?? `Kobo (model ${modelId})`; +} + +export function parseKoboVersionFile(text: string): KoboVersionInfo | null { + const fields = text.trim().split(','); + if (fields.length < 6) return null; + + const firmwareVersion = fields[2]!.trim(); + const modelId = fields[fields.length - 1]!.trim().match(/(\d{3})$/)?.[1]; + if (!modelId || !/^\d+(\.\d+)+$/.test(firmwareVersion)) return null; + + return { firmwareVersion, modelId, model: modelNameForId(modelId) }; +} + +export async function readDeviceVersion(root: FileSystemDirectoryHandle): Promise { + try { + const kobo = await root.getDirectoryHandle('.kobo'); + const file = await (await kobo.getFileHandle('version')).getFile(); + return parseKoboVersionFile(await file.text()); + } catch { + return null; + } +} diff --git a/src/utils/restore.test.ts b/src/utils/restore.test.ts new file mode 100644 index 0000000..30f8882 --- /dev/null +++ b/src/utils/restore.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildBackupZip, buildKoboDb, meta } from '../test/builders.ts'; + +vi.mock('sql.js', async (importOriginal) => + (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), +); + +const { checkCompatibility, parseBackupFile, previewBackup } = await import('./restore.ts'); + +describe('checkCompatibility', () => { + it('warns when restoring a database from a newer schema onto an older firmware', () => { + const result = checkCompatibility( + meta({ device: { model: 'Kobo Libra 2', firmwareVersion: '4.41', schemaVersion: 180 } }), + { + model: 'Kobo Libra 2', + firmwareVersion: '4.38', + schemaVersion: 170, + }, + ); + expect(result.warnings.some((w) => w.code === 'schema-newer')).toBe(true); + expect(result.warnings.some((w) => w.code === 'firmware')).toBe(true); + }); + + it('reports no warnings for the same device and schema', () => { + const created = new Date().toISOString(); + const result = checkCompatibility( + meta({ created, device: { model: 'Kobo Sage', firmwareVersion: '4.41', schemaVersion: 176 } }), + { model: 'Kobo Sage', firmwareVersion: '4.41', schemaVersion: 176 }, + ); + expect(result.warnings).toEqual([]); + }); + + it('flags a different model', () => { + const created = new Date().toISOString(); + const result = checkCompatibility( + meta({ created, device: { model: 'Kobo Clara HD', firmwareVersion: '4.41', schemaVersion: 1 } }), + { + model: 'Kobo Libra 2', + firmwareVersion: '4.41', + schemaVersion: 1, + }, + ); + expect(result.warnings.map((w) => w.code)).toEqual(['model']); + }); +}); + +describe('previewBackup', () => { + it('computes reading statistics from the backup database, not from (possibly wrong) metadata', async () => { + const dbBytes = await buildKoboDb([ + { contentId: 'file:///mnt/onboard/a.epub', title: 'A', time: 7200, percent: 100 }, + { contentId: 'file:///mnt/onboard/b.epub', title: 'B', time: 0, percent: 0 }, + ]); + // Old backups stored seconds in a field labelled minutes. + const zip = await buildBackupZip({ + dbBytes, + metadata: meta({ statistics: { totalBooks: 2, totalAnnotations: 0, totalReadingTime: 7200 } }), + }); + const preview = previewBackup(await parseBackupFile(zip)); + expect(preview.statistics.totalReadingTime).toBe(120); + expect(preview.statistics.booksFinished).toBe(1); + expect(preview.statistics.totalBooks).toBe(2); + }); +}); diff --git a/src/utils/restore.ts b/src/utils/restore.ts index d442562..5e4699f 100644 --- a/src/utils/restore.ts +++ b/src/utils/restore.ts @@ -7,7 +7,7 @@ import { writeFile, writeFileToPath } from './fileSystem.ts'; import { RestoreError, ERROR_CODES, errorMessage } from './errors.ts'; import { validateBackupMetadata } from './validation.ts'; import { openDatabase } from './koboDatabase.ts'; -import type { BackupMetadata, BackupOptions, DeviceInfo } from '../types/kobo.ts'; +import type { BackupMetadata, BackupOptions, DeviceInfo, ReadingStats } from '../types/kobo.ts'; export interface RestoreBookFile { /** Display name (filename only). */ @@ -23,6 +23,8 @@ export interface ParsedBackup { database: ArrayBuffer; bookFiles: RestoreBookFile[]; bookPathMap: Map; + /** Reading statistics computed from the backup database (null if unreadable). */ + stats: ReadingStats | null; file: Blob; valid: true; } @@ -105,7 +107,7 @@ export async function parseBackupFile(file: Blob): Promise { } const database = await (await dbEntry.getData(new BlobWriter())).arrayBuffer(); - const bookPathMap = await extractBookPathsFromDatabase(database); + const { bookPathMap, stats } = await inspectBackupDatabase(database); const bookFiles: RestoreBookFile[] = entries .filter((entry) => entry.filename.startsWith('books/')) @@ -120,7 +122,15 @@ export async function parseBackupFile(file: Blob): Promise { return { name, path: entry.filename, originalPath }; }); - return { metadata: metadata as BackupMetadata, database, bookFiles, bookPathMap, file, valid: true }; + return { + metadata: metadata as BackupMetadata, + database, + bookFiles, + bookPathMap, + stats, + file, + valid: true, + }; } catch (error) { if (error instanceof RestoreError) throw error; throw new RestoreError('Failed to parse backup file', ERROR_CODES.RESTORE_CORRUPTED, { @@ -288,40 +298,72 @@ export async function restoreToDevice( } } +export type CompatibilityCode = 'model' | 'firmware' | 'schema-newer' | 'old-backup'; + +export interface CompatibilityWarning { + code: CompatibilityCode; + /** Values for the localized message (e.g. { from, to } or { days }). */ + params: Record; + /** English fallback message. */ + message: string; +} + export interface CompatibilityResult { compatible: boolean; - warnings: string[]; + warnings: CompatibilityWarning[]; } +const UNKNOWN = new Set(['', 'Unknown', 'Unknown Kobo Device', 'Kobo Device']); +const known = (value: string | undefined): value is string => !!value && !UNKNOWN.has(value); + /** Compare backup metadata with the target device. */ export function checkCompatibility( backupMetadata: BackupMetadata, deviceInfo: Partial | null, ): CompatibilityResult { - const warnings: string[] = []; + const warnings: CompatibilityWarning[] = []; + const add = (code: CompatibilityCode, params: CompatibilityWarning['params'], message: string) => + warnings.push({ code, params, message }); + const backupModel = backupMetadata.device?.model; const backupFw = backupMetadata.device?.firmwareVersion; + const backupSchema = backupMetadata.device?.schemaVersion ?? 0; + const targetSchema = deviceInfo?.schemaVersion ?? 0; - if (backupModel && deviceInfo?.model && backupModel !== deviceInfo.model) { - warnings.push( + if (known(backupModel) && known(deviceInfo?.model) && backupModel !== deviceInfo.model) { + add( + 'model', + { from: backupModel, to: deviceInfo.model }, `Backup is from a different device model (${backupModel} → ${deviceInfo.model}). Settings may not transfer correctly.`, ); } - if (backupFw && deviceInfo?.firmwareVersion && backupFw !== deviceInfo.firmwareVersion) { - warnings.push( + if (known(backupFw) && known(deviceInfo?.firmwareVersion) && backupFw !== deviceInfo.firmwareVersion) { + add( + 'firmware', + { from: backupFw, to: deviceInfo.firmwareVersion }, `Different firmware versions (${backupFw} → ${deviceInfo.firmwareVersion}). This should work but may have minor issues.`, ); } + // Nickel upgrades older schemas on boot, but cannot read a NEWER one. + if (backupSchema > 0 && targetSchema > 0 && backupSchema > targetSchema) { + add( + 'schema-newer', + { from: backupSchema, to: targetSchema }, + `The backup database (schema ${backupSchema}) is newer than this device's firmware supports (schema ${targetSchema}). Update the Kobo firmware before restoring.`, + ); + } if (backupMetadata.created) { - const days = (Date.now() - new Date(backupMetadata.created).getTime()) / 86_400_000; + const days = Math.floor((Date.now() - new Date(backupMetadata.created).getTime()) / 86_400_000); if (days > 180) { - warnings.push( - `This backup is ${Math.floor(days)} days old. Your Kobo may have received firmware updates since then.`, + add( + 'old-backup', + { days }, + `This backup is ${days} days old. Your Kobo may have received firmware updates since then.`, ); } } - return { compatible: true, warnings }; + return { compatible: !warnings.some((w) => w.code === 'schema-newer'), warnings }; } export interface BackupPreviewData { @@ -347,18 +389,21 @@ export interface BackupPreviewData { export function previewBackup({ metadata, bookFiles, -}: Pick): BackupPreviewData { + stats, +}: Pick & Partial>): BackupPreviewData { const s = metadata.statistics; return { created: metadata.created, deviceModel: metadata.device?.model || 'Unknown', firmwareVersion: metadata.device?.firmwareVersion || 'Unknown', + // Prefer values computed from the database itself: metadata written by + // older versions stored reading time in seconds while labelling it minutes. statistics: { - totalBooks: s?.totalBooks || 0, + totalBooks: stats?.totalBooks ?? s?.totalBooks ?? 0, totalAnnotations: s?.totalAnnotations || 0, - booksStarted: s?.booksStarted || 0, - booksFinished: s?.booksFinished || 0, - totalReadingTime: s?.totalReadingTime || 0, + booksStarted: stats?.booksStarted ?? s?.booksStarted ?? 0, + booksFinished: stats?.booksFinished ?? s?.booksFinished ?? 0, + totalReadingTime: stats?.totalMinutesRead ?? s?.totalReadingTime ?? 0, }, contents: { databaseIncluded: true, @@ -402,24 +447,31 @@ export function contentIdToRelativePath(contentId: string): string { return path; } -/** Map filename → original relative path, read from the backup database. */ -async function extractBookPathsFromDatabase(databaseBuffer: ArrayBuffer): Promise> { - const pathMap = new Map(); +/** + * Read the backup database once: filename → original relative path (for old + * flat-format backups) and reading statistics. + */ +async function inspectBackupDatabase( + databaseBuffer: ArrayBuffer, +): Promise<{ bookPathMap: Map; stats: ReadingStats | null }> { + const bookPathMap = new Map(); try { const db = await openDatabase(databaseBuffer); - const books = db.getBooks(); - db.close(); - - for (const book of books) { - const originalPath = contentIdToRelativePath(book.ContentID); - const filename = originalPath.split('/').pop(); - // First occurrence wins for duplicate filenames in different folders. - if (filename && !pathMap.has(filename)) pathMap.set(filename, originalPath); + try { + for (const book of db.getBooks()) { + const originalPath = contentIdToRelativePath(book.ContentID); + const filename = originalPath.split('/').pop(); + // First occurrence wins for duplicate filenames in different folders. + if (filename && !bookPathMap.has(filename)) bookPathMap.set(filename, originalPath); + } + return { bookPathMap, stats: db.getReadingStats() }; + } finally { + db.close(); } } catch (error) { - console.error('[RESTORE] Failed to extract book paths from database:', error); + console.error('[RESTORE] Failed to read backup database:', error); + return { bookPathMap, stats: null }; } - return pathMap; } /** Never recursively delete these top-level entries. */ diff --git a/src/utils/scan.test.ts b/src/utils/scan.test.ts new file mode 100644 index 0000000..38c11ef --- /dev/null +++ b/src/utils/scan.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildKoboDb } from '../test/builders.ts'; +import { freshDevice } from '../test/memoryFs.ts'; + +vi.mock('sql.js', async (importOriginal) => + (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), +); + +const { scanKoboDevice } = await import('./scan.ts'); + +async function deviceWithLibrary() { + const dev = await freshDevice(); + await dev.writeText( + '.kobo/KoboReader.sqlite', + await buildKoboDb( + [{ contentId: 'file:///mnt/onboard/Author/Book.kepub.epub', title: 'Book', time: 600 }], + [], + { + dbVersion: 176, + users: 1, + }, + ), + ); + await dev.writeText( + '.kobo/version', + 'SN,4.1.15,4.41.23145,4.1.15,4.1.15,00000000-0000-0000-0000-000000000388', + ); + await dev.writeText('Author/Book.kepub.epub', 'EPUB'); + await dev.writeText('Author/cover.jpg', 'JPG'); + await dev.writeText('.kobo-images/1/2/x - N3_FULL.parsed', 'IMG'); + return dev; +} + +describe('scanKoboDevice', () => { + it('reads database, device model/firmware and book files (skipping hidden folders)', async () => { + const dev = await deviceWithLibrary(); + const steps: number[] = []; + const result = await scanKoboDevice(dev.handle, (step) => steps.push(step)); + + expect(steps).toEqual([1, 2, 3, 4]); + expect(result.books).toHaveLength(1); + expect(result.books[0]!.TimeSpentReading).toBe(10); + expect(result.deviceInfo).toMatchObject({ + model: 'Kobo Libra 2', + firmwareVersion: '4.41.23145', + schemaVersion: 176, + }); + expect(result.bookFiles.map((f) => f.path)).toEqual(['Author/Book.kepub.epub']); + expect(result.bookFiles[0]!.size).toBe(4); + expect(result.warnings).toEqual([]); + }); + + it('warns when an un-checkpointed WAL file is present (recent changes not in the main DB file)', async () => { + const dev = await deviceWithLibrary(); + await dev.writeText('.kobo/KoboReader.sqlite-wal', new Uint8Array(4096)); + const result = await scanKoboDevice(dev.handle); + expect(result.warnings).toContain('wal-pending'); + }); + + it('ignores an empty WAL file', async () => { + const dev = await deviceWithLibrary(); + await dev.writeText('.kobo/KoboReader.sqlite-wal', new Uint8Array(0)); + expect((await scanKoboDevice(dev.handle)).warnings).toEqual([]); + }); +}); diff --git a/src/utils/scan.ts b/src/utils/scan.ts new file mode 100644 index 0000000..7f7cb62 --- /dev/null +++ b/src/utils/scan.ts @@ -0,0 +1,70 @@ +/** + * Scan a connected Kobo: database, device identity and book files. + */ + +import { getFileByPath, readFile, getAllFiles } from './fileSystem.ts'; +import { extractAllData } from './koboDatabase.ts'; +import { readDeviceVersion } from './koboDevice.ts'; +import { isValidBookFile } from './validation.ts'; +import type { BookFileEntry, ScanResult, ScanWarning } from '../types/kobo.ts'; + +export const SCAN_STEPS = 4; + +/** Returns true when a non-empty WAL file sits next to the database. */ +async function hasPendingWal(root: FileSystemDirectoryHandle): Promise { + try { + const wal = await getFileByPath(root, '.kobo/KoboReader.sqlite-wal'); + return (await wal.getFile()).size > 0; + } catch { + return false; + } +} + +export async function scanKoboDevice( + root: FileSystemDirectoryHandle, + onStep: (step: number) => void = () => {}, +): Promise { + onStep(1); + const [database, version, walPending] = await Promise.all([ + getFileByPath(root, '.kobo/KoboReader.sqlite').then(readFile), + readDeviceVersion(root), + hasPendingWal(root), + ]); + + onStep(2); + const extracted = await extractAllData(database); + const deviceInfo = version + ? { + ...extracted.deviceInfo, + model: version.model, + modelId: version.modelId, + firmwareVersion: version.firmwareVersion, + } + : extracted.deviceInfo; + + onStep(3); + const candidates = (await getAllFiles(root)).filter((file) => isValidBookFile(file.name)); + // File size only (metadata, no content read) so the size estimate is accurate. + const bookFiles: BookFileEntry[] = await Promise.all( + candidates.map(async (file) => { + try { + return { ...file, size: (await file.handle.getFile()).size }; + } catch { + return { ...file, size: 0 }; + } + }), + ); + + onStep(4); + const warnings: ScanWarning[] = walPending ? ['wal-pending'] : []; + return { + books: extracted.books, + annotations: extracted.annotations, + stats: extracted.stats, + collections: extracted.collections, + deviceInfo, + bookFiles, + database, + warnings, + }; +} From 84b6cf41b32f5d651587f9df2dec22d243f107ef Mon Sep 17 00:00:00 2001 From: Fanfulla <224395883+Fanfulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:28:00 +0200 Subject: [PATCH 4/8] feat(security): encrypted backups, pre-restore safety copy, CSP, self-hosted fonts - optional AES-256 password protection for backups (zip.js, streaming); restore detects encrypted archives and reports missing/wrong passwords (the database contains Kobo account tokens) - restore saves the current device database (and a pending WAL) to .kobo/KoboReader.sqlite.before-restore; undoLastRestore() puts it back - restore refuses to write "books" into hidden/system folders (e.g. books/.kobo/KoboReader.sqlite from a crafted archive) - vercel.json: strict Content-Security-Policy (connect-src 'self' makes the "nothing is uploaded" promise enforced by the browser), nosniff, Referrer/Permissions-Policy, immutable caching for hashed assets; COEP removed (sql.js never needed SharedArrayBuffer) - Oswald/Poppins served locally via @fontsource instead of Google Fonts Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- index.html | 11 --- package-lock.json | 33 ++++++-- package.json | 2 + src/hooks/useBackup.ts | 2 + src/hooks/useRestore.ts | 4 +- src/main.jsx | 1 + src/styles/fonts.ts | 14 ++++ src/types/kobo.ts | 2 +- src/utils/backup.ts | 54 +++++++++++-- src/utils/errors.ts | 2 + src/utils/restore.ts | 159 ++++++++++++++++++++++++++++++++----- src/utils/security.test.ts | 133 +++++++++++++++++++++++++++++++ vercel.json | 20 ++++- 13 files changed, 386 insertions(+), 51 deletions(-) create mode 100644 src/styles/fonts.ts create mode 100644 src/utils/security.test.ts diff --git a/index.html b/index.html index 2223ce1..f45cf4d 100644 --- a/index.html +++ b/index.html @@ -95,17 +95,6 @@ ICONS & MANIFEST ====================================================================== --> - - - - - -
diff --git a/package-lock.json b/package-lock.json index d8a43df..4a0f1a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,15 @@ { - "name": "kobo-backup-manager", - "version": "1.0.0", + "name": "kobup", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "kobo-backup-manager", - "version": "1.0.0", + "name": "kobup", + "version": "1.3.0", "dependencies": { + "@fontsource/oswald": "^5.3.0", + "@fontsource/poppins": "^5.3.0", "@vercel/analytics": "^2.0.1", "@zip.js/zip.js": "^2.16.0", "client-zip": "^2.5.1", @@ -36,10 +38,13 @@ "happy-dom": "^20.14.5", "prettier": "^3.9.8", "tailwindcss": "^4.3.3", - "typescript": "^6.0.3", + "typescript": "~6.0.3", "typescript-eslint": "^8.70.0", "vite": "^8.3.0", "vitest": "^5.0.1" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@adobe/css-tools": { @@ -451,6 +456,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@fontsource/oswald": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/oswald/-/oswald-5.3.0.tgz", + "integrity": "sha512-16r2aHActe0pH+9FLdk/74/YxXM7HpGak05kAzx3RvSToiJwGvyhv6GvckPz+YuZmvL1nxWbEop2jAXRcXYPRg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/poppins": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.3.0.tgz", + "integrity": "sha512-cms1nM7U6SN8epIV1WMVOV8VsA645aSm3wHfzIQnkk188U341ThbJGSl2Sv54V29gnDbT2arMPfLzvVIIVCceQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/package.json b/package.json index a4142d9..cf29a43 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "check": "npm run typecheck && npm run lint && npm run test && npm run build" }, "dependencies": { + "@fontsource/oswald": "^5.3.0", + "@fontsource/poppins": "^5.3.0", "@vercel/analytics": "^2.0.1", "@zip.js/zip.js": "^2.16.0", "client-zip": "^2.5.1", diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts index 39be2ff..1583614 100644 --- a/src/hooks/useBackup.ts +++ b/src/hooks/useBackup.ts @@ -21,6 +21,8 @@ export interface CreateBackupOptions extends Partial { /** From showSaveFilePicker (streaming path). */ writableFileHandle?: FileSystemFileHandle | null; suggestedFilename?: string; + /** AES-256 encrypt the archive with this password. */ + password?: string; } export type BackupOutcome = BackupResult & { duration: number }; diff --git a/src/hooks/useRestore.ts b/src/hooks/useRestore.ts index 0772fdd..a9de0e9 100644 --- a/src/hooks/useRestore.ts +++ b/src/hooks/useRestore.ts @@ -29,11 +29,11 @@ export function useRestore() { const [result, setResult] = useState(null); const [error, setError] = useState(null); - const parse = useCallback(async (file: File) => { + const parse = useCallback(async (file: File, password?: string) => { setIsParsing(true); setError(null); try { - const parsed = await parseBackupFile(file); + const parsed = await parseBackupFile(file, password); setBackupData(parsed); setPreview(previewBackup(parsed)); return parsed; diff --git a/src/main.jsx b/src/main.jsx index 3341071..99c2e15 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.jsx'; import { ErrorBoundary } from './components/ErrorBoundary.jsx'; +import './styles/fonts.ts'; import './styles/globals.css'; ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/src/styles/fonts.ts b/src/styles/fonts.ts new file mode 100644 index 0000000..f38e32e --- /dev/null +++ b/src/styles/fonts.ts @@ -0,0 +1,14 @@ +// Self-hosted fonts (no request to Google Fonts: the visitor's IP never leaves +// our origin). Only Latin subsets are bundled; that covers English and Italian. +import '@fontsource/oswald/latin-400.css'; +import '@fontsource/oswald/latin-500.css'; +import '@fontsource/oswald/latin-600.css'; +import '@fontsource/oswald/latin-700.css'; +import '@fontsource/oswald/latin-ext-400.css'; +import '@fontsource/oswald/latin-ext-700.css'; +import '@fontsource/poppins/latin-400.css'; +import '@fontsource/poppins/latin-500.css'; +import '@fontsource/poppins/latin-600.css'; +import '@fontsource/poppins/latin-700.css'; +import '@fontsource/poppins/latin-ext-400.css'; +import '@fontsource/poppins/latin-ext-600.css'; diff --git a/src/types/kobo.ts b/src/types/kobo.ts index c4ec760..5a39a60 100644 --- a/src/types/kobo.ts +++ b/src/types/kobo.ts @@ -126,7 +126,7 @@ export interface BackupMetadata { booksFinished?: number; totalReadingTime?: number; }; - options?: Partial; + options?: Partial & { encrypted?: boolean }; integrity?: { databaseChecksum: string; filesChecked: number; diff --git a/src/utils/backup.ts b/src/utils/backup.ts index bb14d0f..8682516 100644 --- a/src/utils/backup.ts +++ b/src/utils/backup.ts @@ -4,9 +4,14 @@ * WHY: JSZip buffers ALL files simultaneously. For a 4 GB library this peaks * at ~10 GB and throws "RangeError: Array buffer allocation failed". * client-zip streams one file at a time to disk, so peak RAM = 1 book file. + * + * Password-protected backups use zip.js (AES-256, also streaming): the + * database contains Kobo account tokens, so encryption matters when the + * archive is stored in the cloud. */ import { downloadZip } from 'client-zip'; +import { BlobReader, BlobWriter, TextReader, ZipWriter, configure } from '@zip.js/zip.js'; import { downloadBlob } from './fileSystem.ts'; import { BackupError, ERROR_CODES } from './errors.ts'; import type { @@ -25,7 +30,29 @@ export interface BackupResult { metadata: BackupMetadata; } -export type BackupRunOptions = Partial & { onProgress?: ProgressCallback }; +export type BackupRunOptions = Partial & { + onProgress?: ProgressCallback; + /** When set, every entry is encrypted with AES-256. */ + password?: string; +}; + +// Book files are already compressed (EPUB/PDF/CBZ): store them, and keep all +// work on the main thread so no blob: workers are needed under the CSP. +configure({ useWebWorkers: false }); + +async function writeEncryptedZip( + entries: AsyncIterable, + target: BlobWriter | WritableStream, + password: string, +): Promise { + const writer = new ZipWriter(target as never, { password, encryptionStrength: 3, level: 0 }); + for await (const entry of entries) { + const reader = + typeof entry.input === 'string' ? new TextReader(entry.input) : new BlobReader(entry.input); + await writer.add(entry.name, reader, { lastModDate: entry.lastModified }); + } + return writer.close(); +} type IntegrityErrors = NonNullable['errors']; @@ -38,7 +65,7 @@ export interface ZipEntry { function buildMetadata( koboData: ScanResult, - options: Partial, + options: BackupRunOptions, databaseChecksum: string, errors: IntegrityErrors, ): BackupMetadata { @@ -66,7 +93,13 @@ function buildMetadata( booksFinished: koboData.stats?.booksFinished || 0, totalReadingTime: koboData.stats?.totalMinutesRead || 0, }, - options: { includeBooks, includeAnnotations, includeProgress, includeSettings }, + options: { + includeBooks, + includeAnnotations, + includeProgress, + includeSettings, + encrypted: !!options.password, + }, integrity: { databaseChecksum, filesChecked: koboData.bookFiles?.length || 0, @@ -154,15 +187,17 @@ export async function streamBackupToDisk( filename: string, options: BackupRunOptions = {}, ): Promise { - const { onProgress = () => {} } = options; + const { onProgress = () => {}, password } = options; try { const metadata = buildMetadata(koboData, options, await calculateChecksum(koboData.database), []); - const zipResponse = downloadZip(generateZipEntries(koboData, options, onProgress, metadata)); + const entries = generateZipEntries(koboData, options, onProgress, metadata); const writable = await fileHandle.createWritable(); try { - await zipResponse.body!.pipeTo(writable); + if (password) await writeEncryptedZip(entries, writable, password); + else await downloadZip(entries).body!.pipeTo(writable); } catch (err) { + // Uncommitted FileSystemWritableFileStream data is discarded on abort/GC. await writable.abort().catch(() => {}); throw err; } @@ -184,10 +219,13 @@ export async function createBackupBlob( koboData: ScanResult, options: BackupRunOptions = {}, ): Promise { - const { onProgress = () => {} } = options; + const { onProgress = () => {}, password } = options; try { const metadata = buildMetadata(koboData, options, await calculateChecksum(koboData.database), []); - const blob = await downloadZip(generateZipEntries(koboData, options, onProgress, metadata)).blob(); + const entries = generateZipEntries(koboData, options, onProgress, metadata); + const blob = password + ? await writeEncryptedZip(entries, new BlobWriter('application/zip'), password) + : await downloadZip(entries).blob(); onProgress('Backup complete', 100); return { blob, filename: generateBackupFilename(), size: blob.size, metadata }; } catch (error) { diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 8319ae6..d0dee1d 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -12,6 +12,8 @@ export const ERROR_CODES = { RESTORE_INVALID_FILE: 'RESTORE_INVALID_FILE', RESTORE_CORRUPTED: 'RESTORE_CORRUPTED', RESTORE_INCOMPATIBLE: 'RESTORE_INCOMPATIBLE', + RESTORE_PASSWORD_REQUIRED: 'RESTORE_PASSWORD_REQUIRED', + RESTORE_WRONG_PASSWORD: 'RESTORE_WRONG_PASSWORD', FS_PERMISSION_DENIED: 'FS_PERMISSION_DENIED', FS_NOT_FOUND: 'FS_NOT_FOUND', diff --git a/src/utils/restore.ts b/src/utils/restore.ts index 5e4699f..8319489 100644 --- a/src/utils/restore.ts +++ b/src/utils/restore.ts @@ -2,7 +2,15 @@ * Restore utilities: parse backup ZIPs and write them back to a Kobo device. */ -import { ZipReader, BlobReader, BlobWriter, TextWriter, type Entry, type FileEntry } from '@zip.js/zip.js'; +import { + ZipReader, + BlobReader, + BlobWriter, + TextWriter, + ERR_INVALID_PASSWORD, + type Entry, + type FileEntry, +} from '@zip.js/zip.js'; import { writeFile, writeFileToPath } from './fileSystem.ts'; import { RestoreError, ERROR_CODES, errorMessage } from './errors.ts'; import { validateBackupMetadata } from './validation.ts'; @@ -26,6 +34,10 @@ export interface ParsedBackup { /** Reading statistics computed from the backup database (null if unreadable). */ stats: ReadingStats | null; file: Blob; + /** True when the archive entries are AES-encrypted. */ + encrypted: boolean; + /** Kept in memory only, needed to extract books during the restore. */ + password?: string; valid: true; } @@ -46,6 +58,8 @@ export interface RestoreResult { booksRestored: number; failedBooks: FailedBook[]; databaseRestored: boolean; + /** A copy of the previous device database was saved (see undoLastRestore). */ + safetySnapshot: boolean; metadata: BackupMetadata; verification: RestoreVerification | null; } @@ -57,27 +71,38 @@ export interface RestoreProgress { totalFiles?: number; } -export type RestoreRunOptions = Partial< - Pick -> & { +export type RestoreRunOptions = Partial> & { /** * Opt-in: recursively removes top-level book folders before restore. Off by * default because it can delete files added to those folders AFTER the backup. */ cleanExistingBooks?: boolean; + /** Save the current device database before overwriting it (default: true). */ + safetySnapshot?: boolean; onProgress?: (progress: RestoreProgress) => void; }; +/** Name of the pre-restore copy of the device database, inside `.kobo/`. */ +export const SAFETY_SNAPSHOT_NAME = 'KoboReader.sqlite.before-restore'; +const DB_NAME = 'KoboReader.sqlite'; +const DB_SIDECARS = ['KoboReader.sqlite-wal', 'KoboReader.sqlite-shm']; + const isFileEntry = (entry: Entry): entry is FileEntry => !entry.directory; +const isInvalidPassword = (error: unknown) => + error instanceof Error && error.message === ERR_INVALID_PASSWORD; -/** Parse and validate a backup ZIP file. */ -export async function parseBackupFile(file: Blob): Promise { - const zipReader = new ZipReader(new BlobReader(file)); +/** Parse and validate a backup ZIP file (optionally password-protected). */ +export async function parseBackupFile(file: Blob, password?: string): Promise { + const zipReader = new ZipReader(new BlobReader(file), password ? { password } : {}); try { const entries = (await zipReader.getEntries()).filter(isFileEntry); + const encrypted = entries.some((e) => e.encrypted); + if (encrypted && !password) { + throw new RestoreError('This backup is password-protected', ERROR_CODES.RESTORE_PASSWORD_REQUIRED); + } const metadataEntry = entries.find((e) => e.filename === 'backup-metadata.json'); - const dbEntry = entries.find((e) => e.filename === 'KoboReader.sqlite'); + const dbEntry = entries.find((e) => e.filename === DB_NAME); if (!metadataEntry) { throw new RestoreError( @@ -90,11 +115,21 @@ export async function parseBackupFile(file: Blob): Promise { } if (!dbEntry) { throw new RestoreError('Invalid backup: missing KoboReader.sqlite', ERROR_CODES.RESTORE_INVALID_FILE, { - missingFile: 'KoboReader.sqlite', + missingFile: DB_NAME, }); } - const metadata: unknown = JSON.parse(await metadataEntry.getData(new TextWriter())); + let metadataText: string; + try { + metadataText = await metadataEntry.getData(new TextWriter()); + } catch (error) { + if (isInvalidPassword(error)) { + throw new RestoreError('Wrong password for this backup', ERROR_CODES.RESTORE_WRONG_PASSWORD); + } + throw error; + } + + const metadata: unknown = JSON.parse(metadataText); const validation = validateBackupMetadata(metadata); if (!validation.valid) { throw new RestoreError( @@ -129,6 +164,8 @@ export async function parseBackupFile(file: Blob): Promise { bookPathMap, stats, file, + encrypted, + password: encrypted ? password : undefined, valid: true, }; } catch (error) { @@ -141,13 +178,74 @@ export async function parseBackupFile(file: Blob): Promise { } } +/** + * Why a restore path is refused, or null if it is a safe book destination. + * Books never live in hidden/system folders (backups skip them), so writing + * there can only come from a crafted archive, e.g. "books/.kobo/KoboReader.sqlite". + */ +function unsafeBookPathReason(path: string): string | null { + const parts = path.replace(/\\/g, '/').split('/').filter(Boolean); + if (parts.length === 0 || parts.some((p) => p === '..' || p === '.')) + return 'Unsafe path rejected (path traversal)'; + if (parts.some((p) => p.startsWith('.')) || parts[0] === 'System Volume Information') { + return 'Unsafe path rejected (hidden or system folder)'; + } + return null; +} + +async function removeDatabaseSidecars(koboFolder: FileSystemDirectoryHandle): Promise { + for (const name of DB_SIDECARS) await koboFolder.removeEntry(name).catch(() => {}); +} + +async function readOptionalFile(dir: FileSystemDirectoryHandle, name: string): Promise { + try { + return await (await dir.getFileHandle(name)).getFile(); + } catch { + return null; + } +} + +/** Copy the current device database (and a pending WAL) aside before a restore. */ +async function snapshotDeviceDatabase(koboFolder: FileSystemDirectoryHandle): Promise { + const current = await readOptionalFile(koboFolder, DB_NAME); + if (!current || current.size === 0) return false; + await writeFile(koboFolder, SAFETY_SNAPSHOT_NAME, current); + const wal = await readOptionalFile(koboFolder, DB_SIDECARS[0]!); + if (wal && wal.size > 0) await writeFile(koboFolder, `${SAFETY_SNAPSHOT_NAME}-wal`, wal); + else await koboFolder.removeEntry(`${SAFETY_SNAPSHOT_NAME}-wal`).catch(() => {}); + return true; +} + +/** Whether a pre-restore snapshot exists on the device. */ +export async function hasSafetySnapshot(deviceHandle: FileSystemDirectoryHandle): Promise { + try { + const koboFolder = await deviceHandle.getDirectoryHandle('.kobo'); + return ((await readOptionalFile(koboFolder, SAFETY_SNAPSHOT_NAME))?.size ?? 0) > 0; + } catch { + return false; + } +} + +/** Put back the database saved by the last restore. Returns false if none exists. */ +export async function undoLastRestore(deviceHandle: FileSystemDirectoryHandle): Promise { + const koboFolder = await deviceHandle.getDirectoryHandle('.kobo'); + const snapshot = await readOptionalFile(koboFolder, SAFETY_SNAPSHOT_NAME); + if (!snapshot || snapshot.size === 0) return false; + + const snapshotWal = await readOptionalFile(koboFolder, `${SAFETY_SNAPSHOT_NAME}-wal`); + await removeDatabaseSidecars(koboFolder); + await writeFile(koboFolder, DB_NAME, snapshot); + if (snapshotWal && snapshotWal.size > 0) await writeFile(koboFolder, DB_SIDECARS[0]!, snapshotWal); + return true; +} + /** Restore a parsed backup to a Kobo device. */ export async function restoreToDevice( deviceHandle: FileSystemDirectoryHandle, backupData: ParsedBackup, options: RestoreRunOptions = {}, ): Promise { - const { includeBooks = true, cleanExistingBooks = false, onProgress } = options; + const { includeBooks = true, cleanExistingBooks = false, safetySnapshot = true, onProgress } = options; const reportProgress = (stage: string, percent: number, details: Partial = {}) => onProgress?.({ stage, percent, ...details }); @@ -163,6 +261,22 @@ export async function restoreToDevice( }); } + let snapshotSaved = false; + if (safetySnapshot) { + reportProgress('Saving a copy of the current database...', 3); + try { + snapshotSaved = await snapshotDeviceDatabase(koboFolder); + } catch (error) { + throw new RestoreError( + 'Could not save a safety copy of the current database', + ERROR_CODES.RESTORE_FAILED, + { + originalError: error, + }, + ); + } + } + if (includeBooks && cleanExistingBooks && backupData.bookFiles.length > 0) { reportProgress('Removing existing books...', 5); try { @@ -176,9 +290,7 @@ export async function restoreToDevice( try { // CRITICAL: delete WAL/SHM files first. If we overwrite the .sqlite but // leave an old WAL behind, SQLite replays the mismatched WAL and corrupts it. - for (const name of ['KoboReader.sqlite-wal', 'KoboReader.sqlite-shm']) { - await koboFolder.removeEntry(name).catch(() => {}); - } + await removeDatabaseSidecars(koboFolder); reportProgress('Sanitizing database...', 18); let dbData: ArrayBuffer | Uint8Array = backupData.database; @@ -194,7 +306,7 @@ export async function restoreToDevice( console.warn('[RESTORE] Database sanitization failed, using original:', error); } - await writeFile(koboFolder, 'KoboReader.sqlite', dbData); + await writeFile(koboFolder, DB_NAME, dbData); } catch (error) { throw new RestoreError('Failed to write database to device', ERROR_CODES.RESTORE_FAILED, { originalError: error, @@ -205,7 +317,10 @@ export async function restoreToDevice( const failedBooks: FailedBook[] = []; if (includeBooks && backupData.bookFiles.length > 0) { const totalBooks = backupData.bookFiles.length; - const zipReader = new ZipReader(new BlobReader(backupData.file)); + const zipReader = new ZipReader( + new BlobReader(backupData.file), + backupData.password ? { password: backupData.password } : {}, + ); try { const entries = new Map( (await zipReader.getEntries()).filter(isFileEntry).map((entry) => [entry.filename, entry]), @@ -213,11 +328,12 @@ export async function restoreToDevice( for (const [i, bookFile] of backupData.bookFiles.entries()) { const zipEntry = entries.get(bookFile.path); - if (!zipEntry) { + const unsafe = unsafeBookPathReason(bookFile.originalPath); + if (!zipEntry || unsafe) { failedBooks.push({ name: bookFile.name, originalPath: bookFile.originalPath, - error: 'File not found in ZIP archive', + error: unsafe ?? 'File not found in ZIP archive', }); continue; } @@ -264,13 +380,11 @@ export async function restoreToDevice( reportProgress('Verifying restore...', 90); let verification: RestoreVerification | null = null; try { - const restored = await ( - await (await koboFolder.getFileHandle('KoboReader.sqlite')).getFile() - ).arrayBuffer(); + const restored = await (await (await koboFolder.getFileHandle(DB_NAME)).getFile()).arrayBuffer(); const verifyDb = await openDatabase(restored); const dbBooksCount = verifyDb.getBooks().length; verifyDb.close(); - const expectedCount = backupData.metadata?.statistics?.totalBooks || 0; + const expectedCount = backupData.stats?.totalBooks ?? backupData.metadata?.statistics?.totalBooks ?? 0; verification = { dbBooksCount, expectedCount, @@ -287,6 +401,7 @@ export async function restoreToDevice( booksRestored: includeBooks ? backupData.bookFiles.length - failedBooks.length : 0, failedBooks, databaseRestored: true, + safetySnapshot: snapshotSaved, metadata: backupData.metadata, verification, }; diff --git a/src/utils/security.test.ts b/src/utils/security.test.ts new file mode 100644 index 0000000..d9a83a0 --- /dev/null +++ b/src/utils/security.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest'; +import { ZipReader, BlobReader } from '@zip.js/zip.js'; +import { buildBackupZip, buildKoboDb, meta } from '../test/builders.ts'; +import { freshDevice, MemFileHandle } from '../test/memoryFs.ts'; +import type { ScanResult } from '../types/kobo.ts'; + +vi.mock('sql.js', async (importOriginal) => + (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), +); + +const { createBackupBlob } = await import('./backup.ts'); +const { parseBackupFile, restoreToDevice, undoLastRestore, SAFETY_SNAPSHOT_NAME } = + await import('./restore.ts'); +const { ERROR_CODES } = await import('./errors.ts'); + +async function scanResult(): Promise { + const db = await buildKoboDb([{ contentId: 'file:///mnt/onboard/Author/Book.epub', title: 'Book' }], [], { + users: 1, + }); + const bookFile = new MemFileHandle('Book.epub'); + bookFile.data = 'EPUB-CONTENT'; + return { + books: [], + annotations: [], + stats: { + totalBooks: 1, + booksStarted: 0, + booksFinished: 0, + currentlyReading: 0, + totalMinutesRead: 0, + averageProgress: 0, + uniqueAuthors: 1, + }, + collections: [], + deviceInfo: { model: 'Kobo Sage', firmwareVersion: '4.41', databaseVersion: '3', schemaVersion: 176 }, + bookFiles: [ + { + handle: bookFile as unknown as FileSystemFileHandle, + path: 'Author/Book.epub', + name: 'Book.epub', + size: 12, + }, + ], + database: db.buffer, + warnings: [], + }; +} + +describe('encrypted backups (AES-256)', () => { + it('produces a ZIP whose entries are all encrypted and restores with the right password', async () => { + const { blob } = await createBackupBlob(await scanResult(), { password: 'correct horse' }); + + const reader = new ZipReader(new BlobReader(blob)); + const entries = await reader.getEntries(); + await reader.close(); + expect(entries.length).toBeGreaterThan(2); + expect(entries.every((e) => e.encrypted)).toBe(true); + + const parsed = await parseBackupFile(blob, 'correct horse'); + expect(parsed.encrypted).toBe(true); + expect(parsed.bookFiles.map((b) => b.originalPath)).toEqual(['Author/Book.epub']); + + const dev = await freshDevice(); + const res = await restoreToDevice(dev.handle, parsed); + expect(res.failedBooks).toEqual([]); + expect(await (await dev.fileAt('Author/Book.epub')!.getFile()).text()).toBe('EPUB-CONTENT'); + }); + + it('asks for a password when the backup is encrypted', async () => { + const { blob } = await createBackupBlob(await scanResult(), { password: 'secret' }); + await expect(parseBackupFile(blob)).rejects.toMatchObject({ + code: ERROR_CODES.RESTORE_PASSWORD_REQUIRED, + }); + }); + + it('reports a wrong password distinctly', async () => { + const { blob } = await createBackupBlob(await scanResult(), { password: 'secret' }); + await expect(parseBackupFile(blob, 'nope')).rejects.toMatchObject({ + code: ERROR_CODES.RESTORE_WRONG_PASSWORD, + }); + }); + + it('keeps plain backups readable without a password', async () => { + const { blob } = await createBackupBlob(await scanResult()); + const parsed = await parseBackupFile(blob); + expect(parsed.encrypted).toBe(false); + }); +}); + +describe('restore safety net', () => { + async function backupAndDevice() { + const dbBytes = await buildKoboDb([{ contentId: 'file:///mnt/onboard/A/b.epub', title: 'B' }]); + const zip = await buildBackupZip({ + dbBytes, + metadata: meta(), + bookEntries: [{ name: 'books/A/b.epub' }], + }); + const dev = await freshDevice(); + await dev.writeText('.kobo/KoboReader.sqlite', 'ORIGINAL-DEVICE-DB'); + return { parsed: await parseBackupFile(zip), dev }; + } + + it('saves a copy of the current device database before overwriting it', async () => { + const { parsed, dev } = await backupAndDevice(); + const res = await restoreToDevice(dev.handle, parsed); + expect(res.safetySnapshot).toBe(true); + const snapshot = await dev.fileAt(`.kobo/${SAFETY_SNAPSHOT_NAME}`)!.getFile(); + expect(await snapshot.text()).toBe('ORIGINAL-DEVICE-DB'); + }); + + it('can undo the last restore by putting the snapshot back', async () => { + const { parsed, dev } = await backupAndDevice(); + await restoreToDevice(dev.handle, parsed); + await dev.writeText('.kobo/KoboReader.sqlite-wal', 'stale'); + + expect(await undoLastRestore(dev.handle)).toBe(true); + expect(await (await dev.fileAt('.kobo/KoboReader.sqlite')!.getFile()).text()).toBe('ORIGINAL-DEVICE-DB'); + expect(dev.hasFile('.kobo/KoboReader.sqlite-wal')).toBe(false); + }); + + it('never writes book files into hidden/system folders', async () => { + const { parsed, dev } = await backupAndDevice(); + parsed.bookFiles.push({ + name: 'KoboReader.sqlite', + path: 'books/A/b.epub', + originalPath: '.kobo/KoboReader.sqlite', + }); + const res = await restoreToDevice(dev.handle, parsed); + expect(res.failedBooks.map((f) => f.originalPath)).toContain('.kobo/KoboReader.sqlite'); + // The real database written by the restore must not be clobbered by the "book". + expect(await (await dev.fileAt('.kobo/KoboReader.sqlite')!.getFile()).text()).not.toBe('EPUBDATA'); + }); +}); diff --git a/vercel.json b/vercel.json index 126b6b7..7ee0eec 100644 --- a/vercel.json +++ b/vercel.json @@ -1,11 +1,27 @@ { + "$schema": "https://openapi.vercel.sh/vercel.json", + "cleanUrls": true, + "trailingSlash": false, "headers": [ { "source": "/(.*)", "headers": [ - { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }, - { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" } + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; font-src 'self'; connect-src 'self'; worker-src 'self'; manifest-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; upgrade-insecure-requests" + }, + { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + } ] + }, + { + "source": "/assets/(.*)", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] } ] } From d33848800f5abce209133784ec8b147f22174909 Mon Sep 17 00:00:00 2001 From: Fanfulla <224395883+Fanfulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:37:02 +0200 Subject: [PATCH 5/8] feat: merge restore, working covers, backup verification, settings backup - KoboSource abstraction (File System Access handle or plain FileList) used by scanning, backup and covers, with cached directory handles - covers: look in .kobo-images/// using Nickel's qhash (current firmware) before the legacy .kobo/images; verified against real paths - restore "merge" mode: copy progress, highlights/notes and collections of sideloaded books into the device's own database, keeping its account, store library and settings; refuses to run with a pending WAL - backups are read back after writing: entry list + database SHA-256; restore flags archives whose database does not match its checksum - "Device settings" option now real: Kobo eReader.conf, custom fonts and screensavers are backed up under device/ and restored only from a whitelist of paths Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/components/backup/BackupWizard.jsx | 8 +- src/components/backup/CoverPreview.jsx | 14 +- src/hooks/useBackup.ts | 10 +- src/hooks/useKoboDevice.ts | 5 +- src/pages/LibraryDashboard.jsx | 8 +- src/stores/koboStore.ts | 11 +- src/test/builders.ts | 53 +++++-- src/types/kobo.ts | 6 +- src/utils/backup.ts | 137 +++++++++++++--- src/utils/deviceSource.test.ts | 42 +++++ src/utils/deviceSource.ts | 137 ++++++++++++++++ src/utils/errors.ts | 1 + src/utils/koboCovers.test.ts | 78 ++++++---- src/utils/koboCovers.ts | 71 +++++---- src/utils/koboDatabase.ts | 12 ++ src/utils/koboDevice.test.ts | 5 +- src/utils/koboDevice.ts | 12 +- src/utils/merge.test.ts | 207 +++++++++++++++++++++++++ src/utils/merge.ts | 195 +++++++++++++++++++++++ src/utils/restore.ts | 140 ++++++++++++++--- src/utils/scan.test.ts | 44 +++++- src/utils/scan.ts | 85 ++++++---- src/utils/security.test.ts | 10 +- src/utils/verify.test.ts | 103 ++++++++++++ 24 files changed, 1193 insertions(+), 201 deletions(-) create mode 100644 src/utils/deviceSource.test.ts create mode 100644 src/utils/deviceSource.ts create mode 100644 src/utils/merge.test.ts create mode 100644 src/utils/merge.ts create mode 100644 src/utils/verify.test.ts diff --git a/src/components/backup/BackupWizard.jsx b/src/components/backup/BackupWizard.jsx index da4cb1e..70ebfe3 100644 --- a/src/components/backup/BackupWizard.jsx +++ b/src/components/backup/BackupWizard.jsx @@ -8,6 +8,7 @@ import { useKoboDevice } from '../../hooks/useKoboDevice.ts'; import { useBackup } from '../../hooks/useBackup.ts'; import { useKoboStore } from '../../stores/koboStore.ts'; import { generateBackupFilename } from '../../utils/backup.ts'; +import { directorySource } from '../../utils/deviceSource.ts'; import { Container } from '../layout/Container.tsx'; import { DeviceSelector } from './DeviceSelector.jsx'; @@ -36,7 +37,7 @@ export function BackupWizard({ onComplete }) { const setBooks = useKoboStore((state) => state.setBooks); const setAnnotations = useKoboStore((state) => state.setAnnotations); const setStats = useKoboStore((state) => state.setStats); - const setDeviceHandle = useKoboStore((state) => state.setDeviceHandle); + const setSource = useKoboStore((state) => state.setSource); const [koboData, setKoboData] = useState(null); @@ -46,7 +47,8 @@ export function BackupWizard({ onComplete }) { if (dirHandle) { setStep('scanning'); try { - const data = await koboDevice.scanDevice(dirHandle); + const source = directorySource(dirHandle); + const data = await koboDevice.scanDevice(source); setKoboData(data); // Populate global store @@ -54,7 +56,7 @@ export function BackupWizard({ onComplete }) { setBooks(data.books); setAnnotations(data.annotations); setStats(data.stats); - setDeviceHandle(dirHandle); + setSource(source); setStep('overview'); } catch (error) { diff --git a/src/components/backup/CoverPreview.jsx b/src/components/backup/CoverPreview.jsx index 287e02a..7b4906e 100644 --- a/src/components/backup/CoverPreview.jsx +++ b/src/components/backup/CoverPreview.jsx @@ -20,13 +20,7 @@ function getBookColors(title = '') { }; } -export function CoverPreview({ - deviceHandle, - coverId, - title = 'Untitled', - author = 'Unknown', - className = '', -}) { +export function CoverPreview({ source, coverId, title = 'Untitled', author = 'Unknown', className = '' }) { const [coverUrl, setCoverUrl] = useState(null); const [loading, setLoading] = useState(true); const colors = getBookColors(title); @@ -36,13 +30,13 @@ export function CoverPreview({ let url = null; async function loadCover() { - if (!deviceHandle || !coverId) { + if (!source || !coverId) { setLoading(false); return; } try { - url = await getCoverUrl(deviceHandle, coverId); + url = await getCoverUrl(source, coverId); if (active) { setCoverUrl(url); } else if (url) { @@ -66,7 +60,7 @@ export function CoverPreview({ URL.revokeObjectURL(url); } }; - }, [deviceHandle, coverId]); + }, [source, coverId]); const wrapperClass = `relative aspect-3/4 rounded-md shadow-md overflow-hidden transition-all duration-300 group-hover:shadow-lg group-hover:scale-[1.03] select-none ${className}`; diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts index 1583614..fe69541 100644 --- a/src/hooks/useBackup.ts +++ b/src/hooks/useBackup.ts @@ -65,13 +65,9 @@ export function useBackup() { }, ); } else { - const blobResult = await createBackupBlob(koboData, { ...backupOptions, onProgress }); - saveBackup(blobResult.blob, blobResult.filename); - backupResult = { - filename: blobResult.filename, - size: blobResult.size, - metadata: blobResult.metadata, - }; + const { blob, ...blobResult } = await createBackupBlob(koboData, { ...backupOptions, onProgress }); + saveBackup(blob, blobResult.filename); + backupResult = blobResult; } const finalResult = { ...backupResult, duration: Date.now() - startTime }; diff --git a/src/hooks/useKoboDevice.ts b/src/hooks/useKoboDevice.ts index 7c0ad7f..b615e63 100644 --- a/src/hooks/useKoboDevice.ts +++ b/src/hooks/useKoboDevice.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import { scanKoboDevice, SCAN_STEPS } from '../utils/scan.ts'; import { errorCode, errorMessage } from '../utils/errors.ts'; import type { ScanResult, UiError } from '../types/kobo.ts'; +import type { KoboSource } from '../utils/deviceSource.ts'; export interface ScanProgress { /** 1 = reading database, 2 = analyzing books, 3 = finding files, 4 = done. */ @@ -17,11 +18,11 @@ export function useKoboDevice() { const [scanProgress, setScanProgress] = useState(IDLE); const [error, setError] = useState(null); - const scanDevice = useCallback(async (dirHandle: FileSystemDirectoryHandle): Promise => { + const scanDevice = useCallback(async (source: KoboSource): Promise => { setIsScanning(true); setError(null); try { - return await scanKoboDevice(dirHandle, (current) => setScanProgress({ current, total: SCAN_STEPS })); + return await scanKoboDevice(source, (current) => setScanProgress({ current, total: SCAN_STEPS })); } catch (err) { setError({ title: 'Scan Failed', diff --git a/src/pages/LibraryDashboard.jsx b/src/pages/LibraryDashboard.jsx index 6b209a6..908f417 100644 --- a/src/pages/LibraryDashboard.jsx +++ b/src/pages/LibraryDashboard.jsx @@ -12,7 +12,7 @@ export function LibraryDashboard({ onNavigate }) { const annotations = useKoboStore((state) => state.annotations); const stats = useKoboStore((state) => state.stats); const device = useKoboStore((state) => state.device); - const deviceHandle = useKoboStore((state) => state.deviceHandle); + const source = useKoboStore((state) => state.source); // Tab state: 'books' | 'annotations' | 'stats' const [activeTab, setActiveTab] = useState('books'); @@ -253,7 +253,7 @@ export function LibraryDashboard({ onNavigate }) { className="group flex flex-col cursor-pointer text-left focus-visible-ring rounded-md" >
void; setAnnotations: (annotations: KoboAnnotation[]) => void; setStats: (stats: ReadingStats | null) => void; - setDeviceHandle: (deviceHandle: FileSystemDirectoryHandle | null) => void; + setSource: (source: KoboSource | null) => void; addBackup: (backup: Omit) => void; removeBackup: (id: string) => void; setCurrentPage: (page: Page) => void; @@ -43,7 +44,7 @@ export const useKoboStore = create()( books: [], annotations: [], stats: null, - deviceHandle: null, + source: null, backups: [], currentPage: 'home', @@ -51,7 +52,7 @@ export const useKoboStore = create()( setBooks: (books) => set({ books }), setAnnotations: (annotations) => set({ annotations }), setStats: (stats) => set({ stats }), - setDeviceHandle: (deviceHandle) => set({ deviceHandle }), + setSource: (source) => set({ source }), addBackup: (backup) => set((state) => ({ @@ -62,7 +63,7 @@ export const useKoboStore = create()( setCurrentPage: (page) => set({ currentPage: page }), - clearDevice: () => set({ device: null, books: [], annotations: [], stats: null, deviceHandle: null }), + clearDevice: () => set({ device: null, books: [], annotations: [], stats: null, source: null }), }), { name: 'kobo-backup-storage', diff --git a/src/test/builders.ts b/src/test/builders.ts index b4bbe38..049299c 100644 --- a/src/test/builders.ts +++ b/src/test/builders.ts @@ -29,6 +29,8 @@ export interface BookSeed { isbn?: string; publisher?: string; dateLastRead?: string; + chapterBookmarked?: string; + chapters?: { contentId: string; percent?: number }[]; } export interface BookmarkSeed { @@ -39,41 +41,64 @@ export interface BookmarkSeed { date?: string; } +export interface ShelfSeed { + name: string; + contentIds: string[]; + deleted?: boolean; +} + export interface DbSeedOptions { dbVersion?: number; users?: number; + userId?: string; + shelves?: ShelfSeed[]; } export async function buildKoboDb( books: BookSeed[] = [], bookmarks: BookmarkSeed[] = [], - { dbVersion, users = 0 }: DbSeedOptions = {}, + { dbVersion, users = 0, userId, shelves = [] }: DbSeedOptions = {}, ): Promise> { const SQL = await initSqlJs(); const db = new SQL.Database(); + // Keys mirror the real Kobo schema. db.run(`CREATE TABLE content ( - ContentID TEXT, Title TEXT, Attribution TEXT, Description TEXT, Publisher TEXT, + ContentID TEXT NOT NULL, BookID TEXT, Title TEXT, Attribution TEXT, Description TEXT, Publisher TEXT, Series TEXT, SeriesNumber TEXT, ISBN TEXT, Language TEXT, ___PercentRead INTEGER, ReadStatus INTEGER, DateCreated TEXT, DateLastRead TEXT, ImageId TEXT, TimeSpentReading INTEGER, MimeType TEXT, ContentType INTEGER, IsDownloaded TEXT, - BookTitle TEXT, ChapterIDBookmarked TEXT, ___FileSize INTEGER + BookTitle TEXT, ChapterIDBookmarked TEXT, ___FileSize INTEGER, PRIMARY KEY (ContentID) );`); db.run(`CREATE TABLE Bookmark ( - BookmarkID TEXT, VolumeID TEXT, ContentID TEXT, Text TEXT, Annotation TEXT, DateCreated TEXT, + BookmarkID TEXT NOT NULL, VolumeID TEXT, ContentID TEXT, Text TEXT, Annotation TEXT, DateCreated TEXT, DateModified TEXT, StartContainerPath TEXT, StartOffset INTEGER, - EndContainerPath TEXT, EndOffset INTEGER, Hidden TEXT, Type TEXT, Color INTEGER + EndContainerPath TEXT, EndOffset INTEGER, Hidden TEXT, Type TEXT, Color INTEGER, PRIMARY KEY (BookmarkID) );`); db.run(`CREATE TABLE Shelf (CreationDate TEXT, Id TEXT, InternalName TEXT, LastModified TEXT, Name TEXT, - Type TEXT, _IsDeleted TEXT, _IsVisible TEXT, _IsSynced TEXT, _SyncTime TEXT, LastAccessed TEXT);`); + Type TEXT, _IsDeleted TEXT, _IsVisible TEXT, _IsSynced TEXT, _SyncTime TEXT, LastAccessed TEXT, PRIMARY KEY (Id));`); db.run( - `CREATE TABLE ShelfContent (ShelfName TEXT, ContentId TEXT, DateModified TEXT, _IsDeleted TEXT, _IsSynced TEXT);`, + `CREATE TABLE ShelfContent (ShelfName TEXT, ContentId TEXT, DateModified TEXT, _IsDeleted TEXT, _IsSynced TEXT, + PRIMARY KEY (ShelfName, ContentId));`, ); + for (const s of shelves) { + db.run( + `INSERT INTO Shelf (CreationDate, Id, InternalName, LastModified, Name, Type, _IsDeleted, _IsVisible, _IsSynced) + VALUES ('2026-01-01T00:00:00Z', ?, ?, '2026-01-01T00:00:00Z', ?, 'UserTag', ?, 'true', 'false')`, + [`shelf-${s.name}`, s.name, s.name, s.deleted ? 'true' : 'false'], + ); + for (const id of s.contentIds) { + db.run( + `INSERT INTO ShelfContent (ShelfName, ContentId, _IsDeleted, _IsSynced) VALUES (?, ?, 'false', 'false')`, + [s.name, id], + ); + } + } db.run(`CREATE TABLE user (UserID TEXT, UserKey TEXT, UserDisplayName TEXT, UserEmail TEXT, ___DeviceID TEXT, AuthToken TEXT, RefreshToken TEXT, KoboAccessToken TEXT);`); for (let i = 0; i < users; i++) { db.run( `INSERT INTO user VALUES (?, 'key', 'Reader', 'reader@example.com', 'dev', 'auth', 'refresh', 'access')`, - [`user-${i}`], + [userId ?? `user-${i}`], ); } if (dbVersion !== undefined) { @@ -82,8 +107,8 @@ export async function buildKoboDb( } for (const b of books) { db.run( - `INSERT INTO content (ContentID,Title,Attribution,___PercentRead,ReadStatus,ImageId,TimeSpentReading,MimeType,ContentType,IsDownloaded,BookTitle,ISBN,Publisher,DateLastRead) - VALUES (?,?,?,?,?,?,?,?,6,'true',NULL,?,?,?)`, + `INSERT INTO content (ContentID,Title,Attribution,___PercentRead,ReadStatus,ImageId,TimeSpentReading,MimeType,ContentType,IsDownloaded,BookTitle,ISBN,Publisher,DateLastRead,ChapterIDBookmarked) + VALUES (?,?,?,?,?,?,?,?,6,'true',NULL,?,?,?,?)`, [ b.contentId, b.title, @@ -96,8 +121,16 @@ export async function buildKoboDb( b.isbn ?? '111', b.publisher ?? 'Pub', b.dateLastRead ?? '2026-01-01T00:00:00Z', + b.chapterBookmarked ?? null, ], ); + for (const ch of b.chapters ?? []) { + db.run( + `INSERT INTO content (ContentID, BookID, Title, ___PercentRead, ContentType, IsDownloaded, BookTitle) + VALUES (?, ?, 'Chapter', ?, 9, 'true', ?)`, + [ch.contentId, b.contentId, ch.percent ?? 0, b.title], + ); + } } for (const m of bookmarks) { db.run( diff --git a/src/types/kobo.ts b/src/types/kobo.ts index 5a39a60..ede6b46 100644 --- a/src/types/kobo.ts +++ b/src/types/kobo.ts @@ -76,13 +76,13 @@ export interface ExtractedData { databaseSize: number; } -/** A book file found on the device. */ +/** A file found on the device (book, font, settings...). */ export interface BookFileEntry { - handle: FileSystemFileHandle; /** Path relative to the device root, e.g. "Author/Title.kepub.epub". */ path: string; name: string; size: number; + getFile(): Promise; } /** @@ -98,6 +98,8 @@ export interface ScanResult { collections: KoboCollection[]; deviceInfo: DeviceInfo; bookFiles: BookFileEntry[]; + /** Settings, custom fonts and screensavers (backed up with "Device settings"). */ + extraFiles: BookFileEntry[]; database: ArrayBuffer; warnings: ScanWarning[]; } diff --git a/src/utils/backup.ts b/src/utils/backup.ts index 8682516..27a428e 100644 --- a/src/utils/backup.ts +++ b/src/utils/backup.ts @@ -11,12 +11,13 @@ */ import { downloadZip } from 'client-zip'; -import { BlobReader, BlobWriter, TextReader, ZipWriter, configure } from '@zip.js/zip.js'; +import { BlobReader, BlobWriter, TextReader, ZipReader, ZipWriter, configure } from '@zip.js/zip.js'; import { downloadBlob } from './fileSystem.ts'; import { BackupError, ERROR_CODES } from './errors.ts'; import type { BackupMetadata, BackupOptions, + BookFileEntry, KoboAnnotation, ProgressCallback, ScanResult, @@ -24,10 +25,79 @@ import type { export const APP_VERSION = __APP_VERSION__; +/** ZIP layout: book files keep their device-relative path under these prefixes. */ +export const BOOKS_PREFIX = 'books/'; +export const DEVICE_PREFIX = 'device/'; + +export interface BackupVerification { + ok: boolean; + /** Number of entries found in the written archive. */ + entries: number; + /** Entries that were written but cannot be found when reading it back. */ + missing: string[]; + /** Database inside the archive matches the checksum taken before writing. */ + databaseChecksumOk: boolean | null; + error?: string; +} + export interface BackupResult { filename: string; size: number; metadata: BackupMetadata; + /** Read-back check of the saved archive (null if it could not be performed). */ + verification: BackupVerification | null; +} + +/** + * Read an archive back and check it: every expected entry is present and the + * database matches the recorded checksum. Only the database is decompressed, + * so this is fast even for multi-GB libraries. + */ +export async function verifyBackupArchive( + archive: Blob, + { + expectedEntries = [], + databaseChecksum, + password, + }: { expectedEntries?: string[]; databaseChecksum?: string; password?: string }, +): Promise { + const reader = new ZipReader(new BlobReader(archive), password ? { password } : {}); + try { + const entries = await reader.getEntries(); + const names = new Set(entries.map((e) => e.filename)); + const missing = [...new Set(['KoboReader.sqlite', 'backup-metadata.json', ...expectedEntries])].filter( + (name) => !names.has(name), + ); + + let databaseChecksumOk: boolean | null = null; + const db = entries.find((e) => e.filename === 'KoboReader.sqlite'); + if (databaseChecksum?.startsWith('sha256:') && db && !db.directory) { + databaseChecksumOk = (await calculateChecksum(await db.arrayBuffer())) === databaseChecksum; + } + return { + ok: missing.length === 0 && databaseChecksumOk !== false, + entries: entries.length, + missing, + databaseChecksumOk, + }; + } catch (error) { + return { + ok: false, + entries: 0, + missing: [], + databaseChecksumOk: null, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + await reader.close().catch(() => {}); + } +} + +async function* recordNames(entries: AsyncIterable, names: string[]): AsyncGenerator { + for await (const entry of entries) { + names.push(entry.name); + yield entry; + } } export type BackupRunOptions = Partial & { @@ -120,7 +190,7 @@ async function* generateZipEntries( onProgress: ProgressCallback, metadata: BackupMetadata, ): AsyncGenerator { - const { includeBooks = true, includeAnnotations = true } = options; + const { includeBooks = true, includeAnnotations = true, includeSettings = false } = options; // 1. SQLite database (small, safe to buffer) onProgress('Preparing backup...', 0); @@ -130,31 +200,42 @@ async function* generateZipEntries( size: koboData.database.byteLength, }; - // 2. Book files - ONE at a time. handle.getFile() returns a lazy File; - // client-zip streams it to disk before requesting the next one. - if (includeBooks && koboData.bookFiles?.length > 0) { - const total = koboData.bookFiles.length; - for (let i = 0; i < total; i++) { - const bf = koboData.bookFiles[i]!; + // 2. Book files - ONE at a time. getFile() returns a lazy File; the ZIP + // writer streams it to disk before requesting the next one. + async function* files(list: BookFileEntry[], prefix: string, onEach?: (i: number) => void) { + for (const [i, bf] of list.entries()) { try { - const file = await bf.handle.getFile(); + const file = await bf.getFile(); yield { - name: `books/${bf.path || bf.name}`, + name: `${prefix}${bf.path || bf.name}`, input: file, size: file.size, lastModified: new Date(file.lastModified), }; } catch (err) { - console.warn('[BACKUP] Skipping unreadable file:', bf.name, err); + console.warn('[BACKUP] Skipping unreadable file:', bf.path, err); metadata.integrity?.errors.push({ - file: bf.name, + file: bf.path, error: err instanceof Error ? err.message : String(err), }); } - onProgress(`Adding books (${i + 1}/${total})...`, 10 + ((i + 1) / total) * 72, i + 1); + onEach?.(i); } } + if (includeBooks && koboData.bookFiles?.length > 0) { + const total = koboData.bookFiles.length; + yield* files(koboData.bookFiles, BOOKS_PREFIX, (i) => + onProgress(`Adding books (${i + 1}/${total})...`, 10 + ((i + 1) / total) * 72, i + 1), + ); + } + + // 2b. Settings, custom fonts, screensavers (stored with their device path) + if (includeSettings && koboData.extraFiles?.length > 0) { + onProgress('Adding device settings...', 83); + yield* files(koboData.extraFiles, DEVICE_PREFIX); + } + // 3. Annotations (human-readable export) if (includeAnnotations && koboData.annotations?.length > 0) { onProgress('Exporting annotations...', 85); @@ -189,8 +270,10 @@ export async function streamBackupToDisk( ): Promise { const { onProgress = () => {}, password } = options; try { - const metadata = buildMetadata(koboData, options, await calculateChecksum(koboData.database), []); - const entries = generateZipEntries(koboData, options, onProgress, metadata); + const databaseChecksum = await calculateChecksum(koboData.database); + const metadata = buildMetadata(koboData, options, databaseChecksum, []); + const written: string[] = []; + const entries = recordNames(generateZipEntries(koboData, options, onProgress, metadata), written); const writable = await fileHandle.createWritable(); try { @@ -202,9 +285,15 @@ export async function streamBackupToDisk( throw err; } - onProgress('Backup complete', 100); + onProgress('Verifying backup...', 98); const savedFile = await fileHandle.getFile(); - return { filename, size: savedFile.size, metadata }; + const verification = await verifyBackupArchive(savedFile, { + expectedEntries: written, + databaseChecksum, + password, + }); + onProgress('Backup complete', 100); + return { filename, size: savedFile.size, metadata, verification }; } catch (error) { console.error('[BACKUP] Streaming failed:', error); throw new BackupError('Failed to create backup', ERROR_CODES.BACKUP_FAILED, { originalError: error }); @@ -221,13 +310,20 @@ export async function createBackupBlob( ): Promise { const { onProgress = () => {}, password } = options; try { - const metadata = buildMetadata(koboData, options, await calculateChecksum(koboData.database), []); - const entries = generateZipEntries(koboData, options, onProgress, metadata); + const databaseChecksum = await calculateChecksum(koboData.database); + const metadata = buildMetadata(koboData, options, databaseChecksum, []); + const written: string[] = []; + const entries = recordNames(generateZipEntries(koboData, options, onProgress, metadata), written); const blob = password ? await writeEncryptedZip(entries, new BlobWriter('application/zip'), password) : await downloadZip(entries).blob(); + const verification = await verifyBackupArchive(blob, { + expectedEntries: written, + databaseChecksum, + password, + }); onProgress('Backup complete', 100); - return { blob, filename: generateBackupFilename(), size: blob.size, metadata }; + return { blob, filename: generateBackupFilename(), size: blob.size, metadata, verification }; } catch (error) { console.error('[BACKUP] Blob creation failed:', error); throw new BackupError('Failed to create backup', ERROR_CODES.BACKUP_FAILED, { originalError: error }); @@ -310,6 +406,7 @@ function generateReadme(m: BackupMetadata): string { '--------', '- KoboReader.sqlite: Your Kobo database with all reading data', '- books/: All your ebook files', + '- device/: Settings, custom fonts and screensavers (if selected)', '- annotations/: Human-readable export of your highlights and notes', '- backup-metadata.json: Technical metadata about this backup', '', diff --git a/src/utils/deviceSource.test.ts b/src/utils/deviceSource.test.ts new file mode 100644 index 0000000..9f0228d --- /dev/null +++ b/src/utils/deviceSource.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { directorySource, fileListSource } from './deviceSource.ts'; +import { freshDevice } from '../test/memoryFs.ts'; + +function relFile(path: string, content = 'x'): File { + const f = new File([content], path.split('/').pop()!); + Object.defineProperty(f, 'webkitRelativePath', { value: path }); + return f; +} + +describe('directorySource', () => { + it('reads nested files and lists non-hidden files by default', async () => { + const dev = await freshDevice(); + await dev.writeText('.kobo/KoboReader.sqlite', 'DB'); + await dev.writeText('A/b.epub', 'BOOK'); + await dev.writeText('fonts/My.ttf', 'FONT'); + const src = directorySource(dev.handle); + + expect(await (await src.getFile('.kobo/KoboReader.sqlite'))?.text()).toBe('DB'); + expect(await src.getFile('missing/file')).toBeNull(); + expect((await src.listFiles()).map((f) => f.path).sort()).toEqual(['A/b.epub', 'fonts/My.ttf']); + expect((await src.listFiles({ under: '.kobo', includeHidden: true })).map((f) => f.path)).toEqual([ + '.kobo/KoboReader.sqlite', + ]); + }); +}); + +describe('fileListSource', () => { + it('strips the picked folder name and mirrors directorySource', async () => { + const src = fileListSource([ + relFile('KOBOeReader/.kobo/KoboReader.sqlite', 'DB'), + relFile('KOBOeReader/A/b.epub', 'BOOK'), + relFile('KOBOeReader/.kobo-images/1/2/x.parsed'), + ]); + expect(src.kind).toBe('files'); + expect(await (await src.getFile('.kobo/KoboReader.sqlite'))?.text()).toBe('DB'); + expect((await src.listFiles()).map((f) => f.path)).toEqual(['A/b.epub']); + const [book] = await src.listFiles(); + expect(book!.size).toBe(4); + expect(await (await book!.getFile()).text()).toBe('BOOK'); + }); +}); diff --git a/src/utils/deviceSource.ts b/src/utils/deviceSource.ts new file mode 100644 index 0000000..9c85a02 --- /dev/null +++ b/src/utils/deviceSource.ts @@ -0,0 +1,137 @@ +/** + * A read-only view of a connected Kobo, independent of how it was opened: + * - `directory`: File System Access API handle (Chromium; also writable for restore) + * - `files`: a FileList from (Firefox/Safari backup) + */ + +export interface SourceFile { + /** Path relative to the device root, e.g. "Author/Title.kepub.epub". */ + path: string; + name: string; + size: number; + getFile(): Promise; +} + +export interface ListOptions { + /** Include files inside hidden folders (".kobo", ".kobo-images", ...). */ + includeHidden?: boolean; + /** Only list files under this relative folder. */ + under?: string; +} + +export interface KoboSource { + readonly kind: 'directory' | 'files'; + /** Present for `directory` sources: needed for writing (restore). */ + readonly handle?: FileSystemDirectoryHandle; + getFile(path: string): Promise; + listFiles(options?: ListOptions): Promise; +} + +const SYSTEM_FOLDERS = new Set(['System Volume Information', '$RECYCLE.BIN']); + +function splitPath(path: string): string[] { + return path.replace(/\\/g, '/').split('/').filter(Boolean); +} + +const isHiddenSegment = (segment: string) => segment.startsWith('.') || SYSTEM_FOLDERS.has(segment); + +function matches(path: string, { includeHidden = false, under }: ListOptions): boolean { + const parts = splitPath(path); + if (under) { + const prefix = splitPath(under); + if (!prefix.every((p, i) => parts[i] === p)) return false; + } + // The file name itself may start with a dot; only folders make it hidden. + return includeHidden || !parts.slice(0, -1).some(isHiddenSegment); +} + +export function directorySource(root: FileSystemDirectoryHandle): KoboSource { + const dirCache = new Map>(); + + const getDir = (parts: string[]): Promise => { + if (parts.length === 0) return Promise.resolve(root); + const key = parts.join('/'); + let cached = dirCache.get(key); + if (!cached) { + cached = getDir(parts.slice(0, -1)).then((parent) => parent.getDirectoryHandle(parts.at(-1)!)); + // Do not cache failures (the folder may be created later). + cached.catch(() => dirCache.delete(key)); + dirCache.set(key, cached); + } + return cached; + }; + + async function walk( + dir: FileSystemDirectoryHandle, + prefix: string, + options: ListOptions, + out: SourceFile[], + ) { + for await (const entry of dir.values()) { + const path = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.kind === 'directory') { + if (options.includeHidden || !isHiddenSegment(entry.name)) await walk(entry, path, options, out); + } else if (matches(path, options)) { + const handle = entry; + let size = 0; + try { + size = (await handle.getFile()).size; + } catch { + // unreadable entry: keep it with size 0, the backup will report it + } + out.push({ path, name: entry.name, size, getFile: () => handle.getFile() }); + } + } + } + + return { + kind: 'directory', + handle: root, + async getFile(path) { + const parts = splitPath(path); + try { + const dir = await getDir(parts.slice(0, -1)); + return await (await dir.getFileHandle(parts.at(-1)!)).getFile(); + } catch { + return null; + } + }, + async listFiles(options = {}) { + const out: SourceFile[] = []; + const start = options.under ? splitPath(options.under) : []; + try { + await walk(await getDir(start), start.join('/'), options, out); + } catch (error) { + // A missing optional folder (e.g. "fonts") is normal. + if ((error as { name?: string } | null)?.name !== 'NotFoundError') { + console.error('[Source] Error reading directory:', options.under || '/', error); + } + } + return out; + }, + }; +} + +/** + * Build a source from files. Their + * webkitRelativePath starts with the picked folder's name, which is removed. + */ +export function fileListSource(files: Iterable): KoboSource { + const byPath = new Map(); + for (const file of files) { + const parts = splitPath(file.webkitRelativePath || file.name); + byPath.set((parts.length > 1 ? parts.slice(1) : parts).join('/'), file); + } + + return { + kind: 'files', + async getFile(path) { + return byPath.get(splitPath(path).join('/')) ?? null; + }, + async listFiles(options = {}) { + return [...byPath] + .filter(([path]) => matches(path, options)) + .map(([path, file]) => ({ path, name: file.name, size: file.size, getFile: async () => file })); + }, + }; +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index d0dee1d..e79c945 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -14,6 +14,7 @@ export const ERROR_CODES = { RESTORE_INCOMPATIBLE: 'RESTORE_INCOMPATIBLE', RESTORE_PASSWORD_REQUIRED: 'RESTORE_PASSWORD_REQUIRED', RESTORE_WRONG_PASSWORD: 'RESTORE_WRONG_PASSWORD', + RESTORE_DEVICE_BUSY: 'RESTORE_DEVICE_BUSY', FS_PERMISSION_DENIED: 'FS_PERMISSION_DENIED', FS_NOT_FOUND: 'FS_NOT_FOUND', diff --git a/src/utils/koboCovers.test.ts b/src/utils/koboCovers.test.ts index 0129ca9..a11d700 100644 --- a/src/utils/koboCovers.test.ts +++ b/src/utils/koboCovers.test.ts @@ -1,41 +1,57 @@ -import { describe, it, expect, vi } from 'vitest'; -import { getCoverFile } from './koboCovers.ts'; -import { getDirectoryByPath } from './fileSystem.ts'; - -vi.mock('./fileSystem.ts', () => ({ getDirectoryByPath: vi.fn() })); - -const asDir = (value: unknown) => value as FileSystemDirectoryHandle; +import { describe, it, expect } from 'vitest'; +import { coverCandidatePaths, getCoverFile, qhash } from './koboCovers.ts'; +import { directorySource, fileListSource } from './deviceSource.ts'; +import { freshDevice } from '../test/memoryFs.ts'; + +describe('qhash (Nickel .kobo-images sharding)', () => { + // Known device paths reported on MobileRead for real books. + it.each([ + ['file____mnt_onboard_Imports_Brandon_Sanderson_Shadows_Of_Self_-_Brandon_Sanderson_epub', 50, 121], + ['ff0a942a-2f28-4aa2-ba97-318fce090264', 20, 244], + ])('%s → %i/%i', (imageId, dir1, dir2) => { + const h = qhash(imageId); + expect(h & 0xff).toBe(dir1); + expect((h & 0xff00) >> 8).toBe(dir2); + }); -describe('koboCovers.ts - Cover Extractor', () => { - it('returns null if deviceHandle or coverId is missing', async () => { - expect(await getCoverFile(null, 'test-id')).toBeNull(); - expect(await getCoverFile(asDir({}), null)).toBeNull(); + it('builds modern and legacy candidate paths, smallest thumbnail first', () => { + const paths = coverCandidatePaths('ff0a942a-2f28-4aa2-ba97-318fce090264'); + expect(paths[0]).toBe( + '.kobo-images/20/244/ff0a942a-2f28-4aa2-ba97-318fce090264 - N3_LIBRARY_GRID.parsed', + ); + expect(paths).toContain('.kobo-images/20/244/ff0a942a-2f28-4aa2-ba97-318fce090264 - N3_FULL.parsed'); + expect(paths.some((p) => p.startsWith('.kobo/images/'))).toBe(true); }); +}); - it('looks up the cover with suffixes and returns the file if found', async () => { - const mockFile = new File(['mock-content'], 'cover.parsed', { type: 'image/jpeg' }); - const mockImagesDir = { - getFileHandle: vi.fn((filename: string) => - filename === 'my-cover-id - N3_LIBRARY_GRID.parsed' - ? Promise.resolve({ getFile: () => Promise.resolve(mockFile) }) - : Promise.reject(new Error('File not found')), - ), - }; - vi.mocked(getDirectoryByPath).mockResolvedValue(asDir(mockImagesDir)); +describe('getCoverFile', () => { + const id = 'ff0a942a-2f28-4aa2-ba97-318fce090264'; - const device = asDir({}); - const resultFile = await getCoverFile(device, 'my-cover-id'); + it('finds a cover in the modern .kobo-images tree', async () => { + const dev = await freshDevice(); + await dev.writeText(`.kobo-images/20/244/${id} - N3_LIBRARY_FULL.parsed`, 'JPEG'); + const file = await getCoverFile(directorySource(dev.handle), id); + expect(await file?.text()).toBe('JPEG'); + }); - expect(getDirectoryByPath).toHaveBeenCalledWith(device, '.kobo/images'); - expect(mockImagesDir.getFileHandle).toHaveBeenCalledWith('my-cover-id - N3_LIBRARY_GRID.parsed'); - expect(resultFile).toBe(mockFile); + it('falls back to the legacy .kobo/images folder', async () => { + const dev = await freshDevice(); + await dev.writeText(`.kobo/images/${id} - N3_LIBRARY_GRID.parsed`, 'OLD'); + expect(await (await getCoverFile(directorySource(dev.handle), id))?.text()).toBe('OLD'); }); - it('returns null if the cover file does not exist under any suffix', async () => { - const mockImagesDir = { getFileHandle: vi.fn().mockRejectedValue(new Error('File not found')) }; - vi.mocked(getDirectoryByPath).mockResolvedValue(asDir(mockImagesDir)); + it('works with a plain file list (webkitdirectory fallback)', async () => { + const f = new File(['FL'], `${id} - N3_FULL.parsed`); + Object.defineProperty(f, 'webkitRelativePath', { + value: `KOBOeReader/.kobo-images/20/244/${id} - N3_FULL.parsed`, + }); + expect(await (await getCoverFile(fileListSource([f]), id))?.text()).toBe('FL'); + }); - expect(await getCoverFile(asDir({}), 'invalid-cover-id')).toBeNull(); - expect(mockImagesDir.getFileHandle).toHaveBeenCalledTimes(5); + it('returns null when missing or without an id', async () => { + const dev = await freshDevice(); + expect(await getCoverFile(directorySource(dev.handle), id)).toBeNull(); + expect(await getCoverFile(directorySource(dev.handle), null)).toBeNull(); + expect(await getCoverFile(null, id)).toBeNull(); }); }); diff --git a/src/utils/koboCovers.ts b/src/utils/koboCovers.ts index ed1c79b..0c86707 100644 --- a/src/utils/koboCovers.ts +++ b/src/utils/koboCovers.ts @@ -1,44 +1,51 @@ /** - * Fetch cover images from a connected Kobo device. + * Cover thumbnails pre-rendered by the Kobo firmware. + * + * Current firmware stores them in `.kobo-images/// - .parsed` + * where d1/d2 come from a Qt3-era string hash of the ImageId (same algorithm + * as calibre's KoboTouch driver). Very old firmware used a flat `.kobo/images`. + * The `.parsed` files are plain JPEGs. */ -import { getDirectoryByPath } from './fileSystem.ts'; +import type { KoboSource } from './deviceSource.ts'; -/** Suffixes used by Kobo firmware for pre-rendered cover JPEGs. */ -const COVER_SUFFIXES = [ - ' - N3_LIBRARY_GRID.parsed', - ' - N3_LIBRARY_SHELF.parsed', - ' - N3_LIBRARY_LIST.parsed', - ' - N3_LIBRARY_FULL.parsed', - ' - NickelBookCover.parsed', -]; +/** Smallest first: the grid thumbnail is enough for the dashboard. */ +const SUFFIXES = [' - N3_LIBRARY_GRID.parsed', ' - N3_LIBRARY_FULL.parsed', ' - N3_FULL.parsed']; +const LEGACY_SUFFIXES = [...SUFFIXES, ' - N3_LIBRARY_SHELF.parsed', ' - N3_LIBRARY_LIST.parsed']; -/** Retrieve the cover file for an ImageId, or null if not found. */ -export async function getCoverFile( - deviceHandle: FileSystemDirectoryHandle | null, - coverId: string | null, -): Promise { - if (!deviceHandle || !coverId) return null; +const encoder = new TextEncoder(); - try { - const imagesDir = await getDirectoryByPath(deviceHandle, '.kobo/images'); - for (const suffix of COVER_SUFFIXES) { - try { - return await (await imagesDir.getFileHandle(`${coverId}${suffix}`)).getFile(); - } catch { - // Try next suffix - } - } - } catch (error) { - console.debug('[Covers] Could not access .kobo/images directory:', error); +/** Nickel's image-cache hash (28-bit). */ +export function qhash(imageId: string): number { + let h = 0; + for (const byte of encoder.encode(imageId)) { + h = h * 16 + byte; + h ^= (h & 0xf0000000) >>> 23; + h &= 0x0fffffff; + } + return h; +} + +export function coverCandidatePaths(imageId: string): string[] { + const h = qhash(imageId); + const dir = `.kobo-images/${h & 0xff}/${(h & 0xff00) >> 8}`; + return [ + ...SUFFIXES.map((suffix) => `${dir}/${imageId}${suffix}`), + ...LEGACY_SUFFIXES.map((suffix) => `.kobo/images/${imageId}${suffix}`), + ]; +} + +/** The first available cover file for an ImageId, or null. */ +export async function getCoverFile(source: KoboSource | null, imageId: string | null): Promise { + if (!source || !imageId) return null; + for (const path of coverCandidatePaths(imageId)) { + const file = await source.getFile(path); + if (file && file.size > 0) return file; } return null; } /** Object URL for the cover image (caller must revoke it). */ -export async function getCoverUrl( - deviceHandle: FileSystemDirectoryHandle | null, - coverId: string | null, -): Promise { - const file = await getCoverFile(deviceHandle, coverId); +export async function getCoverUrl(source: KoboSource | null, imageId: string | null): Promise { + const file = await getCoverFile(source, imageId); return file ? URL.createObjectURL(file) : null; } diff --git a/src/utils/koboDatabase.ts b/src/utils/koboDatabase.ts index 4a1fd1a..61ea526 100644 --- a/src/utils/koboDatabase.ts +++ b/src/utils/koboDatabase.ts @@ -6,6 +6,7 @@ import type { Database, QueryExecResult, SqlJsStatic, SqlValue } from 'sql.js'; import sqlWasmUrl from 'sql.js/dist/sql-wasm-browser.wasm?url'; import { DatabaseError, ERROR_CODES } from './errors.ts'; +import { mergeReadingData, type MergeOptions, type MergeReport } from './merge.ts'; import type { DeviceInfo, ExtractedData, @@ -263,6 +264,17 @@ export class KoboDatabase { } } + /** Copy reading data of sideloaded books from another (backup) database into this one. */ + mergeReadingDataFrom(source: KoboDatabase, options?: MergeOptions): MergeReport { + try { + return mergeReadingData(this.db, source.db, options); + } catch (error) { + throw new DatabaseError('Failed to merge reading data', ERROR_CODES.DB_WRITE_ERROR, { + originalError: error, + }); + } + } + export(): Uint8Array { return this.db.export() as Uint8Array; } diff --git a/src/utils/koboDevice.test.ts b/src/utils/koboDevice.test.ts index c78baa5..1608d4f 100644 --- a/src/utils/koboDevice.test.ts +++ b/src/utils/koboDevice.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { parseKoboVersionFile, readDeviceVersion, modelNameForId } from './koboDevice.ts'; +import { directorySource } from './deviceSource.ts'; import { MemDirHandle, freshDevice } from '../test/memoryFs.ts'; describe('koboDevice — .kobo/version parsing', () => { @@ -26,13 +27,13 @@ describe('koboDevice — .kobo/version parsing', () => { '.kobo/version', 'SN,4.1.15,4.41.23145,4.1.15,4.1.15,00000000-0000-0000-0000-000000000388', ); - expect(await readDeviceVersion(dev.handle)).toMatchObject({ + expect(await readDeviceVersion(directorySource(dev.handle))).toMatchObject({ model: 'Kobo Libra 2', firmwareVersion: '4.41.23145', }); }); it('returns null when the version file is missing', async () => { - expect(await readDeviceVersion(new MemDirHandle().handle)).toBeNull(); + expect(await readDeviceVersion(directorySource(new MemDirHandle().handle))).toBeNull(); }); }); diff --git a/src/utils/koboDevice.ts b/src/utils/koboDevice.ts index 13039df..df98f94 100644 --- a/src/utils/koboDevice.ts +++ b/src/utils/koboDevice.ts @@ -5,6 +5,7 @@ * ,,,,,00000000-0000-0000-0000-000000000 * The serial number is personal data: it is parsed out and never kept. */ +import type { KoboSource } from './deviceSource.ts'; /** * Model ids as shipped in Nickel (extracted from libnickel by pgaskin/koboutils @@ -62,12 +63,7 @@ export function parseKoboVersionFile(text: string): KoboVersionInfo | null { return { firmwareVersion, modelId, model: modelNameForId(modelId) }; } -export async function readDeviceVersion(root: FileSystemDirectoryHandle): Promise { - try { - const kobo = await root.getDirectoryHandle('.kobo'); - const file = await (await kobo.getFileHandle('version')).getFile(); - return parseKoboVersionFile(await file.text()); - } catch { - return null; - } +export async function readDeviceVersion(source: KoboSource): Promise { + const file = await source.getFile('.kobo/version'); + return file ? parseKoboVersionFile(await file.text()) : null; } diff --git a/src/utils/merge.test.ts b/src/utils/merge.test.ts new file mode 100644 index 0000000..01ac7db --- /dev/null +++ b/src/utils/merge.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildBackupZip, buildKoboDb, meta } from '../test/builders.ts'; +import { freshDevice } from '../test/memoryFs.ts'; + +vi.mock('sql.js', async (importOriginal) => + (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), +); + +const { openDatabase } = await import('./koboDatabase.ts'); +const { parseBackupFile, restoreToDevice } = await import('./restore.ts'); +const { ERROR_CODES } = await import('./errors.ts'); +const initSqlJs = (await import('sql.js')).default; + +const A = 'file:///mnt/onboard/Author/A.kepub.epub'; +const B = 'file:///mnt/onboard/Author/B.kepub.epub'; + +/** Backup from the OLD device: A read to 55% with a highlight, B finished, a shelf. */ +const backupDb = () => + buildKoboDb( + [ + { + contentId: A, + title: 'A', + percent: 55, + readStatus: 1, + time: 3600, + chapterBookmarked: 'OEBPS/ch3.xhtml', + chapters: [{ contentId: `${A}!!ch1`, percent: 100 }], + }, + { + contentId: B, + title: 'B', + percent: 100, + readStatus: 2, + time: 7200, + chapters: [{ contentId: `${B}!!ch1`, percent: 100 }], + }, + ], + [{ id: 'bm-1', volumeId: A, text: 'A highlight', note: 'my note' }], + { + users: 1, + userId: 'OLD-ACCOUNT', + shelves: [ + { name: 'Favourites', contentIds: [A, B] }, + { name: 'Gone', contentIds: [A], deleted: true }, + ], + }, + ); + +/** NEW device: signed into a new account, A already imported but unread, B unknown. */ +const deviceDb = () => + buildKoboDb( + [ + { + contentId: A, + title: 'A', + percent: 0, + readStatus: 0, + chapters: [{ contentId: `${A}!!ch1`, percent: 0 }], + }, + ], + [], + { + users: 1, + userId: 'NEW-ACCOUNT', + }, + ); + +async function query(bytes: Uint8Array, sql: string) { + const SQL = await initSqlJs(); + const db = new SQL.Database(bytes); + const rows = db.exec(sql)[0]?.values ?? []; + db.close(); + return rows; +} + +describe('KoboDatabase.mergeReadingDataFrom', () => { + it('moves progress, annotations and collections without touching the account', async () => { + const target = await openDatabase(await deviceDb()); + const source = await openDatabase(await backupDb()); + const report = target.mergeReadingDataFrom(source); + const out = target.export(); + target.close(); + source.close(); + + expect(report).toEqual({ + booksUpdated: 1, + booksAdded: 1, + annotationsAdded: 1, + collectionsAdded: 1, + collectionEntriesAdded: 2, + }); + expect( + await query( + out, + `SELECT ___PercentRead, ReadStatus, TimeSpentReading, ChapterIDBookmarked FROM content WHERE ContentID = '${A}'`, + ), + ).toEqual([[55, 1, 3600, 'OEBPS/ch3.xhtml']]); + expect(await query(out, `SELECT ___PercentRead FROM content WHERE ContentID = '${A}!!ch1'`)).toEqual([ + [100], + ]); + expect( + await query( + out, + `SELECT Title, ContentType FROM content WHERE ContentID LIKE '${B}%' ORDER BY ContentType`, + ), + ).toEqual([ + ['B', 6], + ['Chapter', 9], + ]); + expect(await query(out, `SELECT Text, Annotation FROM Bookmark`)).toEqual([['A highlight', 'my note']]); + expect(await query(out, `SELECT Name FROM Shelf`)).toEqual([['Favourites']]); + expect((await query(out, `SELECT ContentId FROM ShelfContent ORDER BY ContentId`)).flat()).toEqual([ + A, + B, + ]); + expect(await query(out, `SELECT UserID FROM user`)).toEqual([['NEW-ACCOUNT']]); + }); + + it('is idempotent and honours the per-category switches', async () => { + const target = await openDatabase(await deviceDb()); + const source = await openDatabase(await backupDb()); + target.mergeReadingDataFrom(source, { annotations: false, collections: false }); + const second = target.mergeReadingDataFrom(source, { annotations: false, collections: false }); + const out = target.export(); + target.close(); + source.close(); + + expect(second.booksAdded).toBe(0); + expect(await query(out, 'SELECT COUNT(*) FROM Bookmark')).toEqual([[0]]); + expect(await query(out, 'SELECT COUNT(*) FROM Shelf')).toEqual([[0]]); + }); +}); + +describe('restoreToDevice — merge mode', () => { + it('merges into the device database instead of replacing it', async () => { + const zip = await buildBackupZip({ + dbBytes: await backupDb(), + metadata: meta(), + bookEntries: [{ name: 'books/Author/B.kepub.epub' }], + }); + const parsed = await parseBackupFile(zip); + const dev = await freshDevice(); + await dev.writeText('.kobo/KoboReader.sqlite', await deviceDb()); + + const res = await restoreToDevice(dev.handle, parsed, { mode: 'merge' }); + expect(res.mode).toBe('merge'); + expect(res.merge?.booksAdded).toBe(1); + + const written = new Uint8Array( + await (await dev.fileAt('.kobo/KoboReader.sqlite')!.getFile()).arrayBuffer(), + ); + expect(await query(written, 'SELECT UserID FROM user')).toEqual([['NEW-ACCOUNT']]); + expect(await query(written, `SELECT ___PercentRead FROM content WHERE ContentID = '${A}'`)).toEqual([ + [55], + ]); + expect(dev.hasFile('Author/B.kepub.epub')).toBe(true); + }); + + it('falls back to a full restore when the device has no database yet', async () => { + const zip = await buildBackupZip({ dbBytes: await backupDb(), metadata: meta() }); + const dev = await freshDevice(); + const res = await restoreToDevice(dev.handle, await parseBackupFile(zip), { mode: 'merge' }); + expect(res.mode).toBe('full'); + }); + + it('refuses to merge while the device database has un-checkpointed changes', async () => { + const zip = await buildBackupZip({ dbBytes: await backupDb(), metadata: meta() }); + const dev = await freshDevice(); + await dev.writeText('.kobo/KoboReader.sqlite', await deviceDb()); + await dev.writeText('.kobo/KoboReader.sqlite-wal', new Uint8Array(1024)); + await expect( + restoreToDevice(dev.handle, await parseBackupFile(zip), { mode: 'merge' }), + ).rejects.toMatchObject({ + code: ERROR_CODES.RESTORE_DEVICE_BUSY, + }); + }); +}); + +describe('restoreToDevice — personalisation files', () => { + it('puts back settings, fonts and screensavers only when asked, never other hidden paths', async () => { + const zip = await buildBackupZip({ + dbBytes: await backupDb(), + metadata: meta(), + extraEntries: [ + { name: 'device/.kobo/Kobo/Kobo eReader.conf', content: '[Reading]' }, + { name: 'device/fonts/Literata.ttf', content: 'TTF' }, + { name: 'device/.kobo/KoboReader.sqlite', content: 'EVIL' }, + ], + }); + const parsed = await parseBackupFile(zip); + expect(parsed.extraFiles.map((f) => f.originalPath).sort()).toEqual([ + '.kobo/Kobo/Kobo eReader.conf', + 'fonts/Literata.ttf', + ]); + + const dev = await freshDevice(); + const skipped = await restoreToDevice(dev.handle, parsed); + expect(skipped.settingsRestored).toBe(0); + expect(dev.hasFile('fonts/Literata.ttf')).toBe(false); + + const res = await restoreToDevice(dev.handle, parsed, { includeSettings: true }); + expect(res.settingsRestored).toBe(2); + expect(await (await dev.fileAt('.kobo/Kobo/Kobo eReader.conf')!.getFile()).text()).toBe('[Reading]'); + expect(await (await dev.fileAt('.kobo/KoboReader.sqlite')!.getFile()).text()).not.toBe('EVIL'); + }); +}); diff --git a/src/utils/merge.ts b/src/utils/merge.ts new file mode 100644 index 0000000..a1e200e --- /dev/null +++ b/src/utils/merge.ts @@ -0,0 +1,195 @@ +/** + * "Reading data only" restore: copy progress, highlights/notes and + * collections of sideloaded books from a backup database INTO the target + * device's own database, instead of replacing it. + * + * This keeps the target device's account (user table, auth tokens), store + * library, settings and schema, which is what you want when moving to a new + * Kobo or after re-registering a device. + */ +import type { Database, SqlValue } from 'sql.js'; + +export interface MergeOptions { + progress?: boolean; + annotations?: boolean; + collections?: boolean; +} + +export interface MergeReport { + booksUpdated: number; + booksAdded: number; + annotationsAdded: number; + collectionsAdded: number; + collectionEntriesAdded: number; +} + +type Row = Record; + +/** Per-book reading state columns on the `content` row (subset present in both schemas is used). */ +const PROGRESS_COLUMNS = [ + '___PercentRead', + 'ReadStatus', + 'DateLastRead', + 'ChapterIDBookmarked', + 'TimeSpentReading', + 'RestOfBookEstimate', + 'FirstTimeReading', + 'LastTimeStartedReading', + 'LastTimeFinishedReading', + 'TimesStartedReading', + 'adobe_location', + 'Rating', +]; + +const SIDELOADED = `ContentType = 6 AND ContentID LIKE 'file://%' AND (BookTitle IS NULL OR BookTitle = '')`; + +const quote = (name: string) => `"${name.replace(/"/g, '""')}"`; + +function columns(db: Database, table: string): string[] { + try { + return (db.exec(`PRAGMA table_info(${quote(table)})`)[0]?.values ?? []).map((row) => String(row[1])); + } catch { + return []; + } +} + +function rows(db: Database, sql: string, params: SqlValue[] = []): Row[] { + const stmt = db.prepare(sql); + try { + stmt.bind(params); + const out: Row[] = []; + while (stmt.step()) out.push(stmt.getAsObject() as Row); + return out; + } finally { + stmt.free(); + } +} + +function keySet(db: Database, sql: string): Set { + return new Set(rows(db, sql).map((r) => Object.values(r).map(String).join('\u0000'))); +} + +function inserter(db: Database, table: string, cols: string[]) { + const stmt = db.prepare( + `INSERT OR IGNORE INTO ${quote(table)} (${cols.map(quote).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, + ); + return { + insert: (row: Row) => { + stmt.run(cols.map((c) => row[c] ?? null)); + return db.getRowsModified() > 0; + }, + free: () => stmt.free(), + }; +} + +const common = (a: string[], b: string[]) => a.filter((c) => b.includes(c)); + +export function mergeReadingData( + target: Database, + source: Database, + options: MergeOptions = {}, +): MergeReport { + const { progress = true, annotations = true, collections = true } = options; + const report: MergeReport = { + booksUpdated: 0, + booksAdded: 0, + annotationsAdded: 0, + collectionsAdded: 0, + collectionEntriesAdded: 0, + }; + + const contentCols = common(columns(source, 'content'), columns(target, 'content')); + const hasBookId = contentCols.includes('BookID'); + const books = rows(source, `SELECT * FROM content WHERE ${SIDELOADED}`); + const bookIds = new Set(books.map((b) => String(b.ContentID))); + const existing = keySet(target, 'SELECT ContentID FROM content'); + const chaptersOf = (bookId: SqlValue) => + hasBookId ? rows(source, 'SELECT * FROM content WHERE BookID = ? AND ContentID != BookID', [bookId]) : []; + + target.exec('BEGIN'); + try { + // 1. Books: update reading state of known books, add unknown ones (with chapters). + const progressCols = PROGRESS_COLUMNS.filter((c) => contentCols.includes(c)); + const chapterCols = ['___PercentRead', 'ReadStatus'].filter((c) => contentCols.includes(c)); + const update = (cols: string[]) => + cols.length + ? target.prepare( + `UPDATE content SET ${cols.map((c) => `${quote(c)} = ?`).join(', ')} WHERE ContentID = ?`, + ) + : null; + const updateBook = progress ? update(progressCols) : null; + const updateChapter = progress ? update(chapterCols) : null; + const insertContent = inserter(target, 'content', contentCols); + + for (const book of books) { + const chapters = chaptersOf(book.ContentID); + if (existing.has(String(book.ContentID))) { + if (updateBook) { + updateBook.run([...progressCols.map((c) => book[c] ?? null), book.ContentID]); + report.booksUpdated++; + for (const ch of chapters) { + if (existing.has(String(ch.ContentID))) + updateChapter?.run([...chapterCols.map((c) => ch[c] ?? null), ch.ContentID]); + } + } + } else if (insertContent.insert(book)) { + report.booksAdded++; + for (const ch of chapters) insertContent.insert(ch); + } + } + updateBook?.free(); + updateChapter?.free(); + insertContent.free(); + + // 2. Highlights, notes and bookmarks. + if (annotations && columns(source, 'Bookmark').length) { + const bmCols = common(columns(source, 'Bookmark'), columns(target, 'Bookmark')); + const known = keySet(target, 'SELECT BookmarkID FROM Bookmark'); + const insertBookmark = inserter(target, 'Bookmark', bmCols); + for (const bm of rows(source, 'SELECT * FROM Bookmark')) { + if (!bookIds.has(String(bm.VolumeID)) || known.has(String(bm.BookmarkID))) continue; + if (insertBookmark.insert(bm)) report.annotationsAdded++; + } + insertBookmark.free(); + } + + // 3. Collections (matched by name, deleted ones skipped). + if (collections && columns(source, 'Shelf').length && columns(target, 'Shelf').length) { + const notDeleted = `(_IsDeleted IS NULL OR LOWER(_IsDeleted) != 'true')`; + const shelfCols = common(columns(source, 'Shelf'), columns(target, 'Shelf')); + const knownShelves = keySet(target, 'SELECT Name FROM Shelf'); + const insertShelf = inserter(target, 'Shelf', shelfCols); + const shelves = rows( + source, + `SELECT * FROM Shelf WHERE ${notDeleted} AND (Type IS NULL OR Type != 'SystemTag')`, + ); + for (const shelf of shelves) { + if (knownShelves.has(String(shelf.Name))) continue; + if (insertShelf.insert(shelf)) report.collectionsAdded++; + } + insertShelf.free(); + + const scCols = common(columns(source, 'ShelfContent'), columns(target, 'ShelfContent')); + const shelfNames = new Set(shelves.map((s) => String(s.Name))); + const knownEntries = keySet(target, 'SELECT ShelfName, ContentId FROM ShelfContent'); + const insertEntry = inserter(target, 'ShelfContent', scCols); + for (const entry of rows(source, `SELECT * FROM ShelfContent WHERE ${notDeleted}`)) { + const key = `${entry.ShelfName}\u0000${entry.ContentId}`; + if ( + !shelfNames.has(String(entry.ShelfName)) || + !bookIds.has(String(entry.ContentId)) || + knownEntries.has(key) + ) + continue; + if (insertEntry.insert(entry)) report.collectionEntriesAdded++; + } + insertEntry.free(); + } + + target.exec('COMMIT'); + } catch (error) { + target.exec('ROLLBACK'); + throw error; + } + return report; +} diff --git a/src/utils/restore.ts b/src/utils/restore.ts index 8319489..6e82394 100644 --- a/src/utils/restore.ts +++ b/src/utils/restore.ts @@ -15,6 +15,9 @@ import { writeFile, writeFileToPath } from './fileSystem.ts'; import { RestoreError, ERROR_CODES, errorMessage } from './errors.ts'; import { validateBackupMetadata } from './validation.ts'; import { openDatabase } from './koboDatabase.ts'; +import { calculateChecksum, BOOKS_PREFIX, DEVICE_PREFIX } from './backup.ts'; +import { isPersonalisationPath } from './scan.ts'; +import type { MergeOptions, MergeReport } from './merge.ts'; import type { BackupMetadata, BackupOptions, DeviceInfo, ReadingStats } from '../types/kobo.ts'; export interface RestoreBookFile { @@ -31,8 +34,12 @@ export interface ParsedBackup { database: ArrayBuffer; bookFiles: RestoreBookFile[]; bookPathMap: Map; + /** Settings, custom fonts and screensavers stored under `device/`. */ + extraFiles: RestoreBookFile[]; /** Reading statistics computed from the backup database (null if unreadable). */ stats: ReadingStats | null; + /** Database matches the checksum recorded at backup time (null: not recorded). */ + checksumOk: boolean | null; file: Blob; /** True when the archive entries are AES-encrypted. */ encrypted: boolean; @@ -60,10 +67,21 @@ export interface RestoreResult { databaseRestored: boolean; /** A copy of the previous device database was saved (see undoLastRestore). */ safetySnapshot: boolean; + /** How the database was restored ("merge" falls back to "full" on an empty device). */ + mode: RestoreMode; + merge: MergeReport | null; + settingsRestored: number; metadata: BackupMetadata; verification: RestoreVerification | null; } +/** + * - `full`: replace the device database with the backup (exact copy, including + * the old account and settings). + * - `merge`: copy reading data of sideloaded books into the device database. + */ +export type RestoreMode = 'full' | 'merge'; + export interface RestoreProgress { stage: string; percent: number; @@ -71,7 +89,11 @@ export interface RestoreProgress { totalFiles?: number; } -export type RestoreRunOptions = Partial> & { +export type RestoreRunOptions = Partial> & { + /** Default: `full`. */ + mode?: RestoreMode; + /** What to copy in `merge` mode (all by default). */ + merge?: MergeOptions; /** * Opt-in: recursively removes top-level book folders before restore. Off by * default because it can delete files added to those folders AFTER the backup. @@ -145,11 +167,11 @@ export async function parseBackupFile(file: Blob, password?: string): Promise entry.filename.startsWith('books/')) + .filter((entry) => entry.filename.startsWith(BOOKS_PREFIX)) .map((entry) => { // New format: "books/Author/book.epub" (full relative path embedded in the ZIP) // Old format: "books/book.epub" (filename only, path recovered via pathMap) - const zipRelPath = entry.filename.slice('books/'.length); + const zipRelPath = entry.filename.slice(BOOKS_PREFIX.length); const name = zipRelPath.split('/').pop()!; const originalPath = zipRelPath.includes('/') ? zipRelPath @@ -157,12 +179,28 @@ export async function parseBackupFile(file: Blob, password?: string): Promise entry.filename.startsWith(DEVICE_PREFIX)) + .map((entry) => { + const originalPath = entry.filename.slice(DEVICE_PREFIX.length); + return { name: originalPath.split('/').pop()!, path: entry.filename, originalPath }; + }) + .filter((f) => isPersonalisationPath(f.originalPath)); + + const recorded = (metadata as BackupMetadata).integrity?.databaseChecksum; + const checksumOk = recorded?.startsWith('sha256:') + ? (await calculateChecksum(database)) === recorded + : null; + return { metadata: metadata as BackupMetadata, database, bookFiles, bookPathMap, + extraFiles, stats, + checksumOk, file, encrypted, password: encrypted ? password : undefined, @@ -245,7 +283,13 @@ export async function restoreToDevice( backupData: ParsedBackup, options: RestoreRunOptions = {}, ): Promise { - const { includeBooks = true, cleanExistingBooks = false, safetySnapshot = true, onProgress } = options; + const { + includeBooks = true, + includeSettings = false, + cleanExistingBooks = false, + safetySnapshot = true, + onProgress, + } = options; const reportProgress = (stage: string, percent: number, details: Partial = {}) => onProgress?.({ stage, percent, ...details }); @@ -261,6 +305,21 @@ export async function restoreToDevice( }); } + // Merging needs a complete device database: with a pending WAL the main + // file misses recent changes (e.g. a freshly signed-in account). + let mode: RestoreMode = options.mode ?? 'full'; + const deviceDb = mode === 'merge' ? await readOptionalFile(koboFolder, DB_NAME) : null; + if (mode === 'merge') { + if (!deviceDb || deviceDb.size === 0) { + mode = 'full'; + } else if (((await readOptionalFile(koboFolder, DB_SIDECARS[0]!))?.size ?? 0) > 0) { + throw new RestoreError( + 'The Kobo database has unsaved changes. Eject the Kobo, wait a few seconds, reconnect it and try again.', + ERROR_CODES.RESTORE_DEVICE_BUSY, + ); + } + } + let snapshotSaved = false; if (safetySnapshot) { reportProgress('Saving a copy of the current database...', 3); @@ -286,26 +345,39 @@ export async function restoreToDevice( } } - reportProgress('Restoring database...', 15); + reportProgress(mode === 'merge' ? 'Merging reading data...' : 'Restoring database...', 15); + let mergeReport: MergeReport | null = null; try { - // CRITICAL: delete WAL/SHM files first. If we overwrite the .sqlite but - // leave an old WAL behind, SQLite replays the mismatched WAL and corrupts it. - await removeDatabaseSidecars(koboFolder); - - reportProgress('Sanitizing database...', 18); let dbData: ArrayBuffer | Uint8Array = backupData.database; - try { - const db = await openDatabase(backupData.database); + if (mode === 'merge') { + const target = await openDatabase(await deviceDb!.arrayBuffer()); + const source = await openDatabase(backupData.database); try { - db.sanitize(); - dbData = db.export(); + mergeReport = target.mergeReadingDataFrom(source, options.merge); + target.sanitize(); + dbData = target.export(); } finally { - db.close(); + target.close(); + source.close(); + } + } else { + reportProgress('Sanitizing database...', 18); + try { + const db = await openDatabase(backupData.database); + try { + db.sanitize(); + dbData = db.export(); + } finally { + db.close(); + } + } catch (error) { + console.warn('[RESTORE] Database sanitization failed, using original:', error); } - } catch (error) { - console.warn('[RESTORE] Database sanitization failed, using original:', error); } + // CRITICAL: delete WAL/SHM files first. If we overwrite the .sqlite but + // leave an old WAL behind, SQLite replays the mismatched WAL and corrupts it. + await removeDatabaseSidecars(koboFolder); await writeFile(koboFolder, DB_NAME, dbData); } catch (error) { throw new RestoreError('Failed to write database to device', ERROR_CODES.RESTORE_FAILED, { @@ -315,8 +387,11 @@ export async function restoreToDevice( reportProgress('Database restored', 20); const failedBooks: FailedBook[] = []; - if (includeBooks && backupData.bookFiles.length > 0) { - const totalBooks = backupData.bookFiles.length; + let settingsRestored = 0; + const booksToWrite = includeBooks ? backupData.bookFiles : []; + const extrasToWrite = includeSettings ? backupData.extraFiles : []; + + if (booksToWrite.length + extrasToWrite.length > 0) { const zipReader = new ZipReader( new BlobReader(backupData.file), backupData.password ? { password: backupData.password } : {}, @@ -325,8 +400,9 @@ export async function restoreToDevice( const entries = new Map( (await zipReader.getEntries()).filter(isFileEntry).map((entry) => [entry.filename, entry]), ); + const totalBooks = booksToWrite.length; - for (const [i, bookFile] of backupData.bookFiles.entries()) { + for (const [i, bookFile] of booksToWrite.entries()) { const zipEntry = entries.get(bookFile.path); const unsafe = unsafeBookPathReason(bookFile.originalPath); if (!zipEntry || unsafe) { @@ -370,10 +446,26 @@ export async function restoreToDevice( totalFiles: totalBooks, }); } + + if (extrasToWrite.length > 0) reportProgress('Restoring device settings...', 86); + for (const extra of extrasToWrite) { + const zipEntry = entries.get(extra.path); + if (!zipEntry || !isPersonalisationPath(extra.originalPath)) continue; + try { + await writeFileToPath(deviceHandle, extra.originalPath, await zipEntry.getData(new BlobWriter())); + settingsRestored++; + } catch (error) { + failedBooks.push({ + name: extra.name, + originalPath: extra.originalPath, + error: errorMessage(error), + }); + } + } } finally { await zipReader.close(); } - reportProgress('Books restored', 85); + reportProgress('Files restored', 88); } // Post-restore validation: reopen the database from the device and count books @@ -398,10 +490,14 @@ export async function restoreToDevice( return { success: true, - booksRestored: includeBooks ? backupData.bookFiles.length - failedBooks.length : 0, + booksRestored: + booksToWrite.length - failedBooks.filter((f) => !isPersonalisationPath(f.originalPath)).length, failedBooks, databaseRestored: true, safetySnapshot: snapshotSaved, + mode, + merge: mergeReport, + settingsRestored, metadata: backupData.metadata, verification, }; diff --git a/src/utils/scan.test.ts b/src/utils/scan.test.ts index 38c11ef..f2b51e4 100644 --- a/src/utils/scan.test.ts +++ b/src/utils/scan.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect, vi } from 'vitest'; import { buildKoboDb } from '../test/builders.ts'; import { freshDevice } from '../test/memoryFs.ts'; +import { directorySource } from './deviceSource.ts'; vi.mock('sql.js', async (importOriginal) => (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), ); -const { scanKoboDevice } = await import('./scan.ts'); +const { scanKoboDevice, isPersonalisationPath } = await import('./scan.ts'); async function deviceWithLibrary() { const dev = await freshDevice(); @@ -35,7 +36,7 @@ describe('scanKoboDevice', () => { it('reads database, device model/firmware and book files (skipping hidden folders)', async () => { const dev = await deviceWithLibrary(); const steps: number[] = []; - const result = await scanKoboDevice(dev.handle, (step) => steps.push(step)); + const result = await scanKoboDevice(directorySource(dev.handle), (step) => steps.push(step)); expect(steps).toEqual([1, 2, 3, 4]); expect(result.books).toHaveLength(1); @@ -47,19 +48,52 @@ describe('scanKoboDevice', () => { }); expect(result.bookFiles.map((f) => f.path)).toEqual(['Author/Book.kepub.epub']); expect(result.bookFiles[0]!.size).toBe(4); + expect(result.extraFiles).toEqual([]); expect(result.warnings).toEqual([]); }); + it('collects settings, custom fonts and screensavers as personalisation files', async () => { + const dev = await deviceWithLibrary(); + await dev.writeText('.kobo/Kobo/Kobo eReader.conf', '[Reading]\nfont=Literata'); + await dev.writeText('fonts/Literata-Regular.ttf', 'TTF'); + await dev.writeText('fonts/readme.txt', 'not a font'); + await dev.writeText('.kobo/screensaver/cat.png', 'PNG'); + const result = await scanKoboDevice(directorySource(dev.handle)); + expect(result.extraFiles.map((f) => f.path).sort()).toEqual([ + '.kobo/Kobo/Kobo eReader.conf', + '.kobo/screensaver/cat.png', + 'fonts/Literata-Regular.ttf', + ]); + }); + it('warns when an un-checkpointed WAL file is present (recent changes not in the main DB file)', async () => { const dev = await deviceWithLibrary(); await dev.writeText('.kobo/KoboReader.sqlite-wal', new Uint8Array(4096)); - const result = await scanKoboDevice(dev.handle); - expect(result.warnings).toContain('wal-pending'); + expect((await scanKoboDevice(directorySource(dev.handle))).warnings).toContain('wal-pending'); }); it('ignores an empty WAL file', async () => { const dev = await deviceWithLibrary(); await dev.writeText('.kobo/KoboReader.sqlite-wal', new Uint8Array(0)); - expect((await scanKoboDevice(dev.handle)).warnings).toEqual([]); + expect((await scanKoboDevice(directorySource(dev.handle))).warnings).toEqual([]); + }); + + it('fails clearly when the database is missing', async () => { + const dev = await freshDevice(); + await expect(scanKoboDevice(directorySource(dev.handle))).rejects.toThrow(/KoboReader\.sqlite/); + }); +}); + +describe('isPersonalisationPath', () => { + it.each([ + ['.kobo/Kobo/Kobo eReader.conf', true], + ['fonts/A.otf', true], + ['fonts/sub/A.ttf', true], + ['.kobo/screensaver/a.jpg', true], + ['.kobo/KoboReader.sqlite', false], + ['.kobo/Kobo/other.conf', false], + ['fonts/../.kobo/KoboReader.sqlite', false], + ])('%s → %s', (path, expected) => { + expect(isPersonalisationPath(path)).toBe(expected); }); }); diff --git a/src/utils/scan.ts b/src/utils/scan.ts index 7f7cb62..734bee9 100644 --- a/src/utils/scan.ts +++ b/src/utils/scan.ts @@ -1,35 +1,54 @@ /** - * Scan a connected Kobo: database, device identity and book files. + * Scan a connected Kobo: database, device identity, book files and + * personalisation files (settings, custom fonts, screensavers). */ -import { getFileByPath, readFile, getAllFiles } from './fileSystem.ts'; import { extractAllData } from './koboDatabase.ts'; -import { readDeviceVersion } from './koboDevice.ts'; -import { isValidBookFile } from './validation.ts'; -import type { BookFileEntry, ScanResult, ScanWarning } from '../types/kobo.ts'; +import { parseKoboVersionFile } from './koboDevice.ts'; +import { isValidBookFile, hasValidExtension } from './validation.ts'; +import { FileSystemError, ERROR_CODES } from './errors.ts'; +import type { KoboSource } from './deviceSource.ts'; +import type { ScanResult, ScanWarning } from '../types/kobo.ts'; export const SCAN_STEPS = 4; -/** Returns true when a non-empty WAL file sits next to the database. */ -async function hasPendingWal(root: FileSystemDirectoryHandle): Promise { - try { - const wal = await getFileByPath(root, '.kobo/KoboReader.sqlite-wal'); - return (await wal.getFile()).size > 0; - } catch { - return false; - } +export const DATABASE_PATH = '.kobo/KoboReader.sqlite'; +export const SETTINGS_PATH = '.kobo/Kobo/Kobo eReader.conf'; + +const FONT_EXTENSIONS = ['.ttf', '.otf']; +const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']; + +/** + * Personalisation files that are safe to back up and put back: + * - `.kobo/Kobo/Kobo eReader.conf` (reading preferences, device settings) + * - `fonts/*.ttf|otf` (sideloaded fonts) + * - `.kobo/screensaver/*` (custom sleep screens) + */ +export function isPersonalisationPath(path: string): boolean { + if (path.split('/').some((p) => p === '..' || p === '.')) return false; + if (path === SETTINGS_PATH) return true; + if (path.startsWith('fonts/')) return hasValidExtension(path, FONT_EXTENSIONS); + if (path.startsWith('.kobo/screensaver/')) return hasValidExtension(path, IMAGE_EXTENSIONS); + return false; } export async function scanKoboDevice( - root: FileSystemDirectoryHandle, + source: KoboSource, onStep: (step: number) => void = () => {}, ): Promise { onStep(1); - const [database, version, walPending] = await Promise.all([ - getFileByPath(root, '.kobo/KoboReader.sqlite').then(readFile), - readDeviceVersion(root), - hasPendingWal(root), + const [dbFile, versionFile, walFile] = await Promise.all([ + source.getFile(DATABASE_PATH), + source.getFile('.kobo/version'), + source.getFile(`${DATABASE_PATH}-wal`), ]); + if (!dbFile) { + throw new FileSystemError(`File not found: ${DATABASE_PATH}`, ERROR_CODES.FS_NOT_FOUND, { + path: DATABASE_PATH, + }); + } + const database = await dbFile.arrayBuffer(); + const version = versionFile ? parseKoboVersionFile(await versionFile.text()) : null; onStep(2); const extracted = await extractAllData(database); @@ -43,20 +62,25 @@ export async function scanKoboDevice( : extracted.deviceInfo; onStep(3); - const candidates = (await getAllFiles(root)).filter((file) => isValidBookFile(file.name)); - // File size only (metadata, no content read) so the size estimate is accurate. - const bookFiles: BookFileEntry[] = await Promise.all( - candidates.map(async (file) => { - try { - return { ...file, size: (await file.handle.getFile()).size }; - } catch { - return { ...file, size: 0 }; - } - }), - ); + const [visible, fonts, screensavers, settings] = await Promise.all([ + source.listFiles(), + source.listFiles({ under: 'fonts' }), + source.listFiles({ under: '.kobo/screensaver', includeHidden: true }), + source.getFile(SETTINGS_PATH), + ]); + const bookFiles = visible.filter((f) => isValidBookFile(f.name)); + const extraFiles = [...fonts, ...screensavers].filter((f) => isPersonalisationPath(f.path)); + if (settings) { + extraFiles.unshift({ + path: SETTINGS_PATH, + name: settings.name, + size: settings.size, + getFile: async () => settings, + }); + } onStep(4); - const warnings: ScanWarning[] = walPending ? ['wal-pending'] : []; + const warnings: ScanWarning[] = walFile && walFile.size > 0 ? ['wal-pending'] : []; return { books: extracted.books, annotations: extracted.annotations, @@ -64,6 +88,7 @@ export async function scanKoboDevice( collections: extracted.collections, deviceInfo, bookFiles, + extraFiles, database, warnings, }; diff --git a/src/utils/security.test.ts b/src/utils/security.test.ts index d9a83a0..0ccea08 100644 --- a/src/utils/security.test.ts +++ b/src/utils/security.test.ts @@ -33,14 +33,8 @@ async function scanResult(): Promise { }, collections: [], deviceInfo: { model: 'Kobo Sage', firmwareVersion: '4.41', databaseVersion: '3', schemaVersion: 176 }, - bookFiles: [ - { - handle: bookFile as unknown as FileSystemFileHandle, - path: 'Author/Book.epub', - name: 'Book.epub', - size: 12, - }, - ], + bookFiles: [{ path: 'Author/Book.epub', name: 'Book.epub', size: 12, getFile: () => bookFile.getFile() }], + extraFiles: [], database: db.buffer, warnings: [], }; diff --git a/src/utils/verify.test.ts b/src/utils/verify.test.ts new file mode 100644 index 0000000..850e44b --- /dev/null +++ b/src/utils/verify.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi } from 'vitest'; +import { buildBackupZip, buildKoboDb, meta } from '../test/builders.ts'; +import { MemFileHandle } from '../test/memoryFs.ts'; +import type { ScanResult } from '../types/kobo.ts'; + +vi.mock('sql.js', async (importOriginal) => + (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), +); + +const { createBackupBlob, verifyBackupArchive, calculateChecksum } = await import('./backup.ts'); +const { parseBackupFile } = await import('./restore.ts'); + +async function scan(): Promise { + const db = await buildKoboDb([{ contentId: 'file:///mnt/onboard/A/b.epub', title: 'B' }]); + const book = new MemFileHandle('b.epub'); + book.data = 'BOOK'; + const conf = new MemFileHandle('Kobo eReader.conf'); + conf.data = '[Reading]'; + return { + books: [], + annotations: [], + stats: { + totalBooks: 1, + booksStarted: 0, + booksFinished: 0, + currentlyReading: 0, + totalMinutesRead: 0, + averageProgress: 0, + uniqueAuthors: 1, + }, + collections: [], + deviceInfo: { model: 'Kobo', firmwareVersion: '4.41', databaseVersion: '3', schemaVersion: 176 }, + bookFiles: [{ path: 'A/b.epub', name: 'b.epub', size: 4, getFile: () => book.getFile() }], + extraFiles: [ + { + path: '.kobo/Kobo/Kobo eReader.conf', + name: 'Kobo eReader.conf', + size: 9, + getFile: () => conf.getFile(), + }, + ], + database: db.buffer, + warnings: [], + }; +} + +describe('backup verification', () => { + it('a freshly written backup verifies OK (entries + database checksum)', async () => { + const { blob, metadata, verification } = await createBackupBlob(await scan(), { includeSettings: true }); + expect(verification).toMatchObject({ ok: true, missing: [], databaseChecksumOk: true }); + expect(verification!.entries).toBeGreaterThanOrEqual(5); + expect(metadata.integrity?.databaseChecksum).toMatch(/^sha256:/); + const names = await verifyBackupArchive(blob, { + expectedEntries: ['device/.kobo/Kobo/Kobo eReader.conf'], + }); + expect(names.ok).toBe(true); + }); + + it('detects missing entries and a tampered database', async () => { + const dbBytes = await buildKoboDb(); + const zip = await buildBackupZip({ + dbBytes, + metadata: meta({ + integrity: { databaseChecksum: 'sha256:deadbeef', filesChecked: 0, fileChecksums: {}, errors: [] }, + }), + }); + const result = await verifyBackupArchive(zip, { + expectedEntries: ['books/A/b.epub'], + databaseChecksum: 'sha256:deadbeef', + }); + expect(result.ok).toBe(false); + expect(result.missing).toEqual(['books/A/b.epub']); + expect(result.databaseChecksumOk).toBe(false); + }); +}); + +describe('restore-side integrity check', () => { + it('flags a backup whose database does not match the recorded checksum', async () => { + const dbBytes = await buildKoboDb(); + const good = await buildBackupZip({ + dbBytes, + metadata: meta({ + integrity: { + databaseChecksum: await calculateChecksum(dbBytes), + filesChecked: 0, + fileChecksums: {}, + errors: [], + }, + }), + }); + const bad = await buildBackupZip({ + dbBytes, + metadata: meta({ + integrity: { databaseChecksum: 'sha256:00', filesChecked: 0, fileChecksums: {}, errors: [] }, + }), + }); + expect((await parseBackupFile(good)).checksumOk).toBe(true); + expect((await parseBackupFile(bad)).checksumOk).toBe(false); + expect( + (await parseBackupFile(await buildBackupZip({ dbBytes, metadata: meta() }))).checksumOk, + ).toBeNull(); + }); +}); From eed142911c3c2ec77a6e9c9b60bdf94a8553bf5c Mon Sep 17 00:00:00 2001 From: Fanfulla <224395883+Fanfulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:58:33 +0200 Subject: [PATCH 6/8] feat(ui): rewrite UI in TypeScript with path routing, EN/IT i18n and a11y - History-API router with real URLs (/backup, /it/ripristino, ...), crawlable links, legacy #hash redirects and a 404 page - typed i18n (English + Italian, same shape enforced by the compiler) with localized numbers, dates, sizes, durations, plurals and error messages - per-page , description, canonical, hreflang and JSON-LD kept in sync on navigation - Backup: shared device session with the Library, real options (books, readable highlights, settings/fonts/screensavers), AES-256 password, read-back verification, file handle saved for History - Restore: password unlock, target-device compatibility (model, firmware, schema), merge vs replace mode, visible errors, one-click undo - Library usable on its own: ARIA tabs, lazy covers, collections tab, Obsidian/Anki/Markdown exports, book details in an accessible dialog - History: verify or restore a backup from its stored handle - works without File System Access (folder input) for backup/library - accessibility: skip link, focus management, contrast-safe accent colour, labelled controls, live regions, keyboard-friendly components - ZIP/SQLite code loaded on demand; SSR-safe components (every route is server-rendered in tests) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- eslint.config.js | 4 +- index.html | 2 +- src/App.jsx | 90 --- src/App.tsx | 93 +++ src/app.test.tsx | 34 + src/components/AnalyticsBanner.jsx | 45 -- src/components/AnalyticsBanner.tsx | 33 + src/components/BrowserNotice.tsx | 39 + src/components/BrowserWarning.jsx | 77 -- src/components/ErrorBoundary.jsx | 165 ---- src/components/ErrorBoundary.tsx | 65 ++ src/components/UnsupportedBrowser.jsx | 109 --- src/components/backup/BackupConfiguration.jsx | 80 -- src/components/backup/BackupProgress.jsx | 108 --- src/components/backup/BackupSuccess.jsx | 105 --- src/components/backup/BackupWizard.jsx | 200 ----- src/components/backup/CoverPreview.jsx | 120 --- src/components/backup/DeviceSelector.jsx | 86 --- src/components/backup/LibraryOverview.jsx | 115 --- src/components/backup/ScanningScreen.jsx | 88 --- src/components/common/Alert.tsx | 68 ++ src/components/common/Checkbox.tsx | 83 +- src/components/common/PageHeader.tsx | 19 + src/components/common/ProgressBar.tsx | 4 +- src/components/common/Stepper.tsx | 42 + src/components/common/Tabs.tsx | 84 ++ src/components/components.test.tsx | 72 ++ src/components/device/ConnectDevice.tsx | 100 +++ src/components/layout/Footer.jsx | 113 --- src/components/layout/Footer.tsx | 65 ++ src/components/layout/Header.jsx | 106 --- src/components/layout/Header.tsx | 99 +++ src/components/library/CoverImage.tsx | 94 +++ src/components/restore/BackupPreview.jsx | 126 --- src/components/restore/FileUploader.jsx | 147 ---- .../restore/RestoreDeviceSelector.jsx | 94 --- src/components/restore/RestoreOptions.jsx | 106 --- src/components/restore/RestoreProgress.jsx | 86 --- src/components/restore/RestoreSuccess.jsx | 165 ---- src/components/restore/RestoreWizard.jsx | 146 ---- src/hooks/useBackup.ts | 103 ++- src/hooks/useDevice.ts | 87 +++ src/hooks/useFeatureDetection.ts | 6 +- src/hooks/useFileSystem.ts | 50 -- src/hooks/useKoboDevice.ts | 44 -- src/hooks/useLocalFlag.ts | 35 + src/hooks/useRestore.ts | 179 +++-- src/i18n/core.ts | 87 +++ src/i18n/en.ts | 724 +++++++++++++++++ src/i18n/i18n.test.ts | 61 ++ src/i18n/index.tsx | 8 + src/i18n/it.ts | 730 ++++++++++++++++++ src/main.jsx | 14 - src/main.tsx | 16 + src/pages/Backup.jsx | 9 - src/pages/Backup.tsx | 419 ++++++++++ src/pages/FAQ.jsx | 244 ------ src/pages/Faq.tsx | 63 ++ src/pages/Guide.tsx | 89 +++ src/pages/History.jsx | 181 ----- src/pages/History.tsx | 197 +++++ src/pages/Home.jsx | 229 ------ src/pages/Home.tsx | 145 ++++ src/pages/Library.tsx | 635 +++++++++++++++ src/pages/LibraryDashboard.jsx | 705 ----------------- src/pages/NotFound.tsx | 20 + src/pages/Privacy.tsx | 53 ++ src/pages/PrivacyPolicy.jsx | 384 --------- src/pages/Restore.jsx | 9 - src/pages/Restore.tsx | 605 +++++++++++++++ src/pages/UserGuide.jsx | 230 ------ src/router/Router.tsx | 77 ++ src/router/context.ts | 31 + src/router/routes.test.ts | 34 + src/router/routes.ts | 66 ++ src/seo/meta.ts | 138 ++++ src/seo/useDocumentMeta.ts | 63 ++ src/stores/koboStore.ts | 63 +- src/styles/globals.css | 2 + src/types/kobo.ts | 11 +- src/utils/backup.ts | 60 +- src/utils/backupInfo.ts | 27 + src/utils/checksum.ts | 10 + src/utils/fileSystem.ts | 10 +- src/utils/formatters.ts | 55 -- src/utils/handleStore.ts | 72 ++ src/utils/koboDatabase.ts | 16 +- src/utils/restore.ts | 85 +- vite.config.ts | 6 +- 89 files changed, 5733 insertions(+), 4931 deletions(-) delete mode 100644 src/App.jsx create mode 100644 src/App.tsx create mode 100644 src/app.test.tsx delete mode 100644 src/components/AnalyticsBanner.jsx create mode 100644 src/components/AnalyticsBanner.tsx create mode 100644 src/components/BrowserNotice.tsx delete mode 100644 src/components/BrowserWarning.jsx delete mode 100644 src/components/ErrorBoundary.jsx create mode 100644 src/components/ErrorBoundary.tsx delete mode 100644 src/components/UnsupportedBrowser.jsx delete mode 100644 src/components/backup/BackupConfiguration.jsx delete mode 100644 src/components/backup/BackupProgress.jsx delete mode 100644 src/components/backup/BackupSuccess.jsx delete mode 100644 src/components/backup/BackupWizard.jsx delete mode 100644 src/components/backup/CoverPreview.jsx delete mode 100644 src/components/backup/DeviceSelector.jsx delete mode 100644 src/components/backup/LibraryOverview.jsx delete mode 100644 src/components/backup/ScanningScreen.jsx create mode 100644 src/components/common/Alert.tsx create mode 100644 src/components/common/PageHeader.tsx create mode 100644 src/components/common/Stepper.tsx create mode 100644 src/components/common/Tabs.tsx create mode 100644 src/components/components.test.tsx create mode 100644 src/components/device/ConnectDevice.tsx delete mode 100644 src/components/layout/Footer.jsx create mode 100644 src/components/layout/Footer.tsx delete mode 100644 src/components/layout/Header.jsx create mode 100644 src/components/layout/Header.tsx create mode 100644 src/components/library/CoverImage.tsx delete mode 100644 src/components/restore/BackupPreview.jsx delete mode 100644 src/components/restore/FileUploader.jsx delete mode 100644 src/components/restore/RestoreDeviceSelector.jsx delete mode 100644 src/components/restore/RestoreOptions.jsx delete mode 100644 src/components/restore/RestoreProgress.jsx delete mode 100644 src/components/restore/RestoreSuccess.jsx delete mode 100644 src/components/restore/RestoreWizard.jsx create mode 100644 src/hooks/useDevice.ts delete mode 100644 src/hooks/useFileSystem.ts delete mode 100644 src/hooks/useKoboDevice.ts create mode 100644 src/hooks/useLocalFlag.ts create mode 100644 src/i18n/core.ts create mode 100644 src/i18n/en.ts create mode 100644 src/i18n/i18n.test.ts create mode 100644 src/i18n/index.tsx create mode 100644 src/i18n/it.ts delete mode 100644 src/main.jsx create mode 100644 src/main.tsx delete mode 100644 src/pages/Backup.jsx create mode 100644 src/pages/Backup.tsx delete mode 100644 src/pages/FAQ.jsx create mode 100644 src/pages/Faq.tsx create mode 100644 src/pages/Guide.tsx delete mode 100644 src/pages/History.jsx create mode 100644 src/pages/History.tsx delete mode 100644 src/pages/Home.jsx create mode 100644 src/pages/Home.tsx create mode 100644 src/pages/Library.tsx delete mode 100644 src/pages/LibraryDashboard.jsx create mode 100644 src/pages/NotFound.tsx create mode 100644 src/pages/Privacy.tsx delete mode 100644 src/pages/PrivacyPolicy.jsx delete mode 100644 src/pages/Restore.jsx create mode 100644 src/pages/Restore.tsx delete mode 100644 src/pages/UserGuide.jsx create mode 100644 src/router/Router.tsx create mode 100644 src/router/context.ts create mode 100644 src/router/routes.test.ts create mode 100644 src/router/routes.ts create mode 100644 src/seo/meta.ts create mode 100644 src/seo/useDocumentMeta.ts create mode 100644 src/utils/backupInfo.ts create mode 100644 src/utils/checksum.ts delete mode 100644 src/utils/formatters.ts create mode 100644 src/utils/handleStore.ts diff --git a/eslint.config.js b/eslint.config.js index 009d0e6..58814a7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,10 +24,12 @@ export default defineConfig([ }, rules: { '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrors: 'none' }], + // Labels wrapping an input plus a title/description span nest text 3 levels deep. + 'jsx-a11y-x/label-has-associated-control': ['error', { depth: 3 }], }, }, { - files: ['**/*.test.{js,jsx,ts,tsx}', 'scripts/**', '*.config.{js,ts}'], + files: ['**/*.test.{js,jsx,ts,tsx}', 'src/test/**', 'scripts/**', '*.config.{js,ts}'], languageOptions: { globals: { ...globals.node, ...globals.browser } }, rules: { 'react-refresh/only-export-components': 'off' }, }, diff --git a/index.html b/index.html index f45cf4d..892dbf3 100644 --- a/index.html +++ b/index.html @@ -98,6 +98,6 @@ </head> <body> <div id="root"></div> - <script type="module" src="/src/main.jsx"></script> + <script type="module" src="/src/main.tsx"></script> </body> </html> diff --git a/src/App.jsx b/src/App.jsx deleted file mode 100644 index 4039888..0000000 --- a/src/App.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Analytics } from '@vercel/analytics/react'; -import { useFeatureDetection } from './hooks/useFeatureDetection.ts'; -import { useKoboStore } from './stores/koboStore.ts'; - -// Layout -import { Header } from './components/layout/Header.jsx'; -import { Footer } from './components/layout/Footer.jsx'; - -// Browser compatibility -import { UnsupportedBrowser } from './components/UnsupportedBrowser.jsx'; -import { BrowserWarning } from './components/BrowserWarning.jsx'; - -// Analytics banner -import { AnalyticsBanner } from './components/AnalyticsBanner.jsx'; - -// Pages -import { Home } from './pages/Home.jsx'; -import { Backup } from './pages/Backup.jsx'; -import { Restore } from './pages/Restore.jsx'; -import { History } from './pages/History.jsx'; -import { UserGuide } from './pages/UserGuide.jsx'; -import { FAQ } from './pages/FAQ.jsx'; -import { PrivacyPolicy } from './pages/PrivacyPolicy.jsx'; -import { LibraryDashboard } from './pages/LibraryDashboard.jsx'; - -function App() { - const features = useFeatureDetection(); - const currentPage = useKoboStore((state) => state.currentPage); - const setCurrentPage = useKoboStore((state) => state.setCurrentPage); - - // Show error page for completely unsupported browsers - if (!features.isSupported) { - const missingFeatures = []; - if (!features.webAssembly) missingFeatures.push('WebAssembly'); - - return <UnsupportedBrowser missingFeatures={missingFeatures} />; - } - - const handleNavigate = (page) => { - setCurrentPage(page); - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - const renderPage = () => { - switch (currentPage) { - case 'home': - return <Home onNavigate={handleNavigate} />; - case 'dashboard': - return <LibraryDashboard onNavigate={handleNavigate} />; - case 'backup': - return <Backup onComplete={() => handleNavigate('home')} />; - case 'restore': - return <Restore onComplete={() => handleNavigate('home')} />; - case 'history': - return <History onNavigate={handleNavigate} />; - case 'guide': - return <UserGuide onNavigate={handleNavigate} />; - case 'faq': - return <FAQ onNavigate={handleNavigate} />; - case 'privacy': - return <PrivacyPolicy onNavigate={handleNavigate} />; - default: - return <Home onNavigate={handleNavigate} />; - } - }; - - return ( - <div className="min-h-screen bg-kobo-cream flex flex-col"> - {/* Browser compatibility warning */} - <BrowserWarning features={features} /> - - {/* Header */} - <Header currentPage={currentPage} onNavigate={handleNavigate} /> - - {/* Main content */} - <main className="flex-1">{renderPage()}</main> - - {/* Footer */} - <Footer onNavigate={handleNavigate} /> - - {/* Analytics disclosure banner */} - <AnalyticsBanner onNavigate={handleNavigate} /> - - {/* Vercel Analytics - cookieless, privacy-preserving */} - <Analytics /> - </div> - ); -} - -export default App; diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..cd67423 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,93 @@ +import { useEffect, type ComponentType } from 'react'; +import { Analytics } from '@vercel/analytics/react'; +import { RouterProvider } from './router/Router.tsx'; +import { useRouter } from './router/context.ts'; +import type { RouteId } from './router/routes.ts'; +import { useI18n } from './i18n/core.ts'; +import { I18nProvider } from './i18n/index.tsx'; +import { useDocumentMeta } from './seo/useDocumentMeta.ts'; +import { useKoboStore } from './stores/koboStore.ts'; +import { Header } from './components/layout/Header.tsx'; +import { Footer } from './components/layout/Footer.tsx'; +import { BrowserNotice } from './components/BrowserNotice.tsx'; +import { AnalyticsBanner } from './components/AnalyticsBanner.tsx'; +import { ErrorBoundary } from './components/ErrorBoundary.tsx'; +import { Home } from './pages/Home.tsx'; +import { Backup } from './pages/Backup.tsx'; +import { Restore } from './pages/Restore.tsx'; +import { Library } from './pages/Library.tsx'; +import { History } from './pages/History.tsx'; +import { Guide } from './pages/Guide.tsx'; +import { Faq } from './pages/Faq.tsx'; +import { Privacy } from './pages/Privacy.tsx'; +import { NotFound } from './pages/NotFound.tsx'; + +const PAGES: Record<RouteId | 'notFound', ComponentType> = { + home: Home, + backup: Backup, + restore: Restore, + library: Library, + history: History, + guide: Guide, + faq: Faq, + privacy: Privacy, + notFound: NotFound, +}; + +function Shell() { + const { route, path } = useRouter(); + const { m } = useI18n(); + const Page = PAGES[route.id]; + useDocumentMeta(route); + + // Persisted history is loaded after hydration so the prerendered HTML matches. + useEffect(() => { + void useKoboStore.persist.rehydrate(); + }, []); + + // Move focus to the page on navigation (screen readers announce the new page). + useEffect(() => { + if (document.activeElement && document.activeElement !== document.body) { + document.getElementById('main')?.focus({ preventScroll: true }); + } + window.scrollTo({ top: 0 }); + }, [path]); + + return ( + <div className="flex min-h-screen flex-col bg-kobo-cream"> + <a + href="#main" + className="sr-only z-50 rounded bg-kobo-dark px-4 py-2 text-white focus:not-sr-only focus:fixed focus:left-4 focus:top-4" + > + {m.common.skipToContent} + </a> + <BrowserNotice /> + <Header /> + <main id="main" tabIndex={-1} className="flex-1 outline-hidden"> + <ErrorBoundary resetKey={path}> + <Page /> + </ErrorBoundary> + </main> + <Footer /> + <AnalyticsBanner /> + {import.meta.env.PROD && !import.meta.env.SSR && <Analytics />} + </div> + ); +} + +function LocalizedShell() { + const { route } = useRouter(); + return ( + <I18nProvider lang={route.lang}> + <Shell /> + </I18nProvider> + ); +} + +export default function App({ path }: { path?: string }) { + return ( + <RouterProvider initialPath={path}> + <LocalizedShell /> + </RouterProvider> + ); +} diff --git a/src/app.test.tsx b/src/app.test.tsx new file mode 100644 index 0000000..2f78ca5 --- /dev/null +++ b/src/app.test.tsx @@ -0,0 +1,34 @@ +/** + * Server-renders every route in every language: this is exactly what the + * build-time prerenderer does, so it guards against browser-only code in render. + */ +import { describe, it, expect } from 'vitest'; +import { renderToString } from 'react-dom/server'; +import App from './App.tsx'; +import { LANGS, ROUTE_IDS, pathFor } from './router/routes.ts'; +import { MESSAGES } from './i18n/core.ts'; + +describe('server rendering', () => { + for (const lang of LANGS) { + for (const id of ROUTE_IDS) { + it(`renders ${pathFor(id, lang)}`, () => { + const html = renderToString(<App path={pathFor(id, lang)} />); + expect(html).toContain('<h1'); + expect(html).toContain(MESSAGES[lang].common.skipToContent); + // Language switcher links to the same page in the other language. + const other = lang === 'en' ? 'it' : 'en'; + expect(html).toContain(`href="${pathFor(id, other)}"`); + }); + } + } + + it('renders the 404 page for unknown paths', () => { + expect(renderToString(<App path="/does-not-exist" />)).toContain(MESSAGES.en.notFound.title); + expect(renderToString(<App path="/it/non-esiste" />)).toContain(MESSAGES.it.notFound.title); + }); + + it('puts FAQ answers in the HTML (crawlable, not behind JS)', () => { + const html = renderToString(<App path="/it/faq" />); + expect(html).toContain(MESSAGES.it.faq.categories[0]!.items[0]!.a.slice(0, 40)); + }); +}); diff --git a/src/components/AnalyticsBanner.jsx b/src/components/AnalyticsBanner.jsx deleted file mode 100644 index b5386a2..0000000 --- a/src/components/AnalyticsBanner.jsx +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Analytics disclosure banner - shown once on first visit. - * Informs the user that only Vercel Analytics (cookieless) is used. - */ - -import { useState } from 'react'; - -const STORAGE_KEY = 'analytics_banner_dismissed'; - -export function AnalyticsBanner({ onNavigate }) { - const [visible, setVisible] = useState(() => !localStorage.getItem(STORAGE_KEY)); - - const dismiss = () => { - localStorage.setItem(STORAGE_KEY, '1'); - setVisible(false); - }; - - if (!visible) return null; - - return ( - <div className="fixed bottom-0 left-0 right-0 z-50 bg-kobo-dark text-kobo-cream shadow-lg"> - <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-3 flex flex-col sm:flex-row items-start sm:items-center gap-3"> - <p className="flex-1 text-sm"> - This site uses <strong>Vercel Analytics</strong> — a cookieless, privacy-preserving service that - collects only anonymous page-view counts. No personal data, no cookies, no tracking across sites.{' '} - <button - onClick={() => { - dismiss(); - onNavigate('privacy'); - }} - className="underline text-kobo-accent hover:text-kobo-accent/80 transition-colors" - > - Privacy Policy - </button> - </p> - <button - onClick={dismiss} - className="shrink-0 bg-kobo-accent hover:bg-kobo-accent/90 text-kobo-dark font-semibold text-sm px-4 py-1.5 rounded-sm transition-colors" - > - OK, got it - </button> - </div> - </div> - ); -} diff --git a/src/components/AnalyticsBanner.tsx b/src/components/AnalyticsBanner.tsx new file mode 100644 index 0000000..dbdda3f --- /dev/null +++ b/src/components/AnalyticsBanner.tsx @@ -0,0 +1,33 @@ +import { Link } from '../router/Router.tsx'; +import { useI18n } from '../i18n/core.ts'; +import { useLocalFlag } from '../hooks/useLocalFlag.ts'; + +/** One-time disclosure of the cookieless analytics. */ +export function AnalyticsBanner() { + const { m } = useI18n(); + const [dismissed, dismiss] = useLocalFlag('analytics_banner_dismissed'); + if (dismissed) return null; + + return ( + <section + aria-label={m.analytics.policy} + className="fixed inset-x-0 bottom-0 z-50 bg-kobo-dark text-kobo-cream shadow-lg" + > + <div className="mx-auto flex max-w-7xl flex-col items-start gap-3 px-4 py-3 sm:flex-row sm:items-center sm:px-6 lg:px-8"> + <p className="flex-1 text-sm"> + {m.analytics.text}{' '} + <Link to="privacy" className="text-kobo-accent underline hover:text-white" onClick={dismiss}> + {m.analytics.policy} + </Link> + </p> + <button + type="button" + onClick={dismiss} + className="shrink-0 rounded bg-kobo-accent px-4 py-1.5 text-sm font-semibold text-kobo-dark hover:bg-kobo-accent/90 focus-visible-ring" + > + {m.analytics.ok} + </button> + </div> + </section> + ); +} diff --git a/src/components/BrowserNotice.tsx b/src/components/BrowserNotice.tsx new file mode 100644 index 0000000..2594fdd --- /dev/null +++ b/src/components/BrowserNotice.tsx @@ -0,0 +1,39 @@ +import { useI18n } from '../i18n/core.ts'; +import { useFeatureDetection } from '../hooks/useFeatureDetection.ts'; +import { useLocalFlag } from '../hooks/useLocalFlag.ts'; +import { Icon } from './common/Icon.tsx'; + +/** Explains what works in browsers without the File System Access API. */ +export function BrowserNotice() { + const { m } = useI18n(); + const features = useFeatureDetection(); + const [dismissed, dismiss] = useLocalFlag('browser_notice_dismissed'); + + if (!features.isSupported) { + return ( + <div role="alert" className="bg-kobo-error/10 px-4 py-4 text-center text-sm text-kobo-dark"> + <strong className="font-semibold">{m.browser.unsupportedTitle}.</strong> {m.browser.unsupportedBody} + </div> + ); + } + if (features.fileSystemAccess || dismissed) return null; + + return ( + <div className="border-b border-kobo-warning/40 bg-kobo-warning/15"> + <div className="mx-auto flex max-w-7xl items-start gap-3 px-4 py-3 sm:px-6 lg:px-8"> + <Icon type="info" className="mt-0.5 shrink-0 text-kobo-dark" /> + <p className="flex-1 text-sm text-kobo-dark"> + <strong className="font-semibold">{m.browser.limitedTitle}.</strong> {m.browser.limitedBody} + </p> + <button + type="button" + onClick={dismiss} + className="rounded p-1 hover:bg-kobo-warning/30 focus-visible-ring" + aria-label={m.browser.dismiss} + > + <Icon type="x" className="text-kobo-dark" /> + </button> + </div> + </div> + ); +} diff --git a/src/components/BrowserWarning.jsx b/src/components/BrowserWarning.jsx deleted file mode 100644 index 920631a..0000000 --- a/src/components/BrowserWarning.jsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Browser warning banner for limited support browsers - */ - -import { useState } from 'react'; -import { StatusBadge } from './common/StatusBadge.tsx'; -import { Button } from './common/Button.tsx'; - -export function BrowserWarning({ features }) { - const [dismissed, setDismissed] = useState(false); - - if (dismissed || features.hasModernFeatures) { - return null; - } - - return ( - <div className="bg-kobo-warning/10 border-b-2 border-kobo-warning/30 py-4 animate-slide-down"> - <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> - <div className="flex items-start gap-4"> - <div className="shrink-0 mt-0.5"> - <svg className="w-6 h-6 text-kobo-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" - /> - </svg> - </div> - - <div className="flex-1"> - <h3 className="font-display font-semibold text-kobo-dark mb-2">Limited Browser Support</h3> - <p className="text-sm text-kobo-gray mb-3"> - Your browser has limited support for this application. For the best experience, we recommend - using Google Chrome 86+, Microsoft Edge 86+, or Opera 72+. - </p> - <div className="flex flex-wrap gap-2"> - <StatusBadge status="warning">Slower backup process</StatusBadge> - <StatusBadge status="warning">Manual file selection required</StatusBadge> - </div> - <div className="mt-3 flex gap-3"> - <Button size="sm" variant="ghost" onClick={() => setDismissed(true)}> - Continue Anyway - </Button> - <a - href="https://www.google.com/chrome/" - target="_blank" - rel="noopener noreferrer" - className="text-sm font-medium text-kobo-accent hover:text-kobo-accent-dark inline-flex items-center gap-1" - > - Download Chrome - <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" - /> - </svg> - </a> - </div> - </div> - - <button - onClick={() => setDismissed(true)} - className="shrink-0 p-1 hover:bg-kobo-warning/20 rounded-sm" - aria-label="Dismiss warning" - > - <svg className="w-5 h-5 text-kobo-gray" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> - </svg> - </button> - </div> - </div> - </div> - ); -} diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx deleted file mode 100644 index 5e50f27..0000000 --- a/src/components/ErrorBoundary.jsx +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Error boundary - catches React errors and displays friendly error UI - */ - -import { Component } from 'react'; -import { Card } from './common/Card.tsx'; -import { Button } from './common/Button.tsx'; -import { Icon } from './common/Icon.tsx'; - -export class ErrorBoundary extends Component { - constructor(props) { - super(props); - this.state = { - hasError: false, - error: null, - errorInfo: null, - }; - } - - static getDerivedStateFromError() { - // Update state so the next render will show the fallback UI - return { hasError: true }; - } - - componentDidCatch(error, errorInfo) { - // Log error details to console for debugging - console.error('ErrorBoundary caught an error:', error, errorInfo); - - // Update state with error details - this.setState({ - error, - errorInfo, - }); - - // In production, you might want to log this to an error reporting service - // logErrorToService(error, errorInfo); - } - - handleReset = () => { - // Clear error state and attempt to recover - this.setState({ - hasError: false, - error: null, - errorInfo: null, - }); - - // Optionally reload the page - if (this.props.onReset) { - this.props.onReset(); - } - }; - - handleReload = () => { - window.location.reload(); - }; - - render() { - if (this.state.hasError) { - // Render fallback UI - return ( - <div className="min-h-screen bg-kobo-cream flex items-center justify-center p-4"> - <div className="max-w-2xl w-full"> - <Card> - <div className="text-center"> - {/* Error Icon */} - <div className="w-24 h-24 bg-kobo-error/10 rounded-full flex items-center justify-center mx-auto mb-6"> - <Icon type="alert" size={48} className="text-kobo-error" /> - </div> - - {/* Error Heading */} - <h1 className="text-4xl font-display font-bold text-kobo-dark mb-4"> - Oops! Something Went Wrong - </h1> - - <p className="text-lg text-kobo-gray mb-8"> - We encountered an unexpected error. Don't worry, your data is safe. - </p> - - {/* Error Details (in development) */} - {import.meta.env.DEV && this.state.error && ( - <div className="mb-8 text-left"> - <details className="bg-kobo-cream-dark rounded-lg p-4"> - <summary className="font-semibold text-kobo-dark cursor-pointer mb-2"> - Error Details (Development Only) - </summary> - <div className="text-sm text-kobo-gray font-mono overflow-x-auto"> - <p className="mb-2"> - <strong>Error:</strong> {this.state.error.toString()} - </p> - {this.state.errorInfo && ( - <pre className="whitespace-pre-wrap text-xs bg-white p-3 rounded-sm border border-kobo-gray-light"> - {this.state.errorInfo.componentStack} - </pre> - )} - </div> - </details> - </div> - )} - - {/* Recovery Suggestions */} - <div className="text-left p-6 bg-kobo-info/10 border border-kobo-info/20 rounded-lg mb-8"> - <h3 className="font-semibold text-kobo-dark mb-3 flex items-center gap-2"> - <Icon type="info" size={20} /> - Try These Steps - </h3> - <ul className="space-y-2 text-sm text-kobo-gray"> - <li className="flex items-start gap-2"> - <span className="text-kobo-accent mt-1">•</span> - <span>Click "Try Again" to attempt recovery</span> - </li> - <li className="flex items-start gap-2"> - <span className="text-kobo-accent mt-1">•</span> - <span>Refresh the page to start fresh</span> - </li> - <li className="flex items-start gap-2"> - <span className="text-kobo-accent mt-1">•</span> - <span>Make sure your Kobo device is properly connected</span> - </li> - <li className="flex items-start gap-2"> - <span className="text-kobo-accent mt-1">•</span> - <span>Check that your browser is up to date</span> - </li> - <li className="flex items-start gap-2"> - <span className="text-kobo-accent mt-1">•</span> - <span>Clear your browser cache and try again</span> - </li> - </ul> - </div> - - {/* Action Buttons */} - <div className="flex flex-col sm:flex-row gap-4 justify-center"> - <Button size="lg" variant="primary" onClick={this.handleReset}> - Try Again - </Button> - <Button size="lg" variant="secondary" onClick={this.handleReload}> - <Icon type="refresh" size={20} /> - Reload Page - </Button> - </div> - - {/* Support Link */} - <div className="mt-8 pt-6 border-t border-kobo-cream-dark"> - <p className="text-sm text-kobo-gray"> - If the problem persists, please{' '} - <a - href="https://github.com/Fanfulla/KoboOfflineBackup/issues" - target="_blank" - rel="noopener noreferrer" - className="text-kobo-accent hover:underline font-semibold" - > - report this issue on GitHub - </a> - </p> - </div> - </div> - </Card> - </div> - </div> - ); - } - - // Render children normally when there's no error - return this.props.children; - } -} diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..365bef0 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,65 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { useI18n } from '../i18n/core.ts'; +import { REPO_URL } from '../seo/meta.ts'; +import { Button } from './common/Button.tsx'; +import { Card } from './common/Card.tsx'; +import { Icon } from './common/Icon.tsx'; + +function ErrorFallback({ error, onReset }: { error: Error; onReset: () => void }) { + const { m } = useI18n(); + return ( + <div className="flex min-h-[60vh] items-center justify-center p-4"> + <Card className="max-w-xl text-center" role="alert"> + <Icon type="alert" size={48} className="mx-auto mb-4 text-kobo-error" /> + <h1 className="mb-3 text-3xl text-kobo-dark">{m.errors.genericTitle}</h1> + <p className="mb-6 text-kobo-gray">{m.errors.genericBody}</p> + {import.meta.env.DEV && ( + <pre className="mb-6 overflow-x-auto text-left text-xs text-kobo-gray">{error.stack}</pre> + )} + <div className="flex flex-col justify-center gap-3 sm:flex-row"> + <Button onClick={onReset}>{m.common.retry}</Button> + <Button variant="secondary" onClick={() => window.location.reload()}> + {m.errors.reload} + </Button> + </div> + <a + href={`${REPO_URL}/issues`} + target="_blank" + rel="noopener noreferrer" + className="mt-6 inline-block text-sm text-kobo-accent-ink underline" + > + {m.errors.report} + </a> + </Card> + </div> + ); +} + +interface State { + error: Error | null; +} + +/** Catches render errors in a page without taking down the whole layout. */ +export class ErrorBoundary extends Component<{ children: ReactNode; resetKey?: string }, State> { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('ErrorBoundary caught an error:', error, info.componentStack); + } + + componentDidUpdate(prev: { resetKey?: string }) { + if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null }); + } + + render() { + return this.state.error ? ( + <ErrorFallback error={this.state.error} onReset={() => this.setState({ error: null })} /> + ) : ( + this.props.children + ); + } +} diff --git a/src/components/UnsupportedBrowser.jsx b/src/components/UnsupportedBrowser.jsx deleted file mode 100644 index ddadaf5..0000000 --- a/src/components/UnsupportedBrowser.jsx +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Full-page error for completely unsupported browsers - */ - -import { Card } from './common/Card.tsx'; - -export function UnsupportedBrowser({ missingFeatures = [] }) { - const recommendedBrowsers = [ - { - name: 'Google Chrome', - version: '86+', - url: 'https://www.google.com/chrome/', - icon: '🌐', - }, - { - name: 'Microsoft Edge', - version: '86+', - url: 'https://www.microsoft.com/edge', - icon: '🌊', - }, - { - name: 'Opera', - version: '72+', - url: 'https://www.opera.com/', - icon: '🎭', - }, - ]; - - return ( - <div className="min-h-screen bg-kobo-cream flex items-center justify-center p-4"> - <Card className="max-w-2xl w-full"> - <div className="text-center"> - {/* Error Icon */} - <div className="w-20 h-20 bg-kobo-error/10 rounded-full flex items-center justify-center mx-auto mb-6"> - <svg className="w-12 h-12 text-kobo-error" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" - /> - </svg> - </div> - - <h1 className="text-4xl font-display font-bold text-kobo-dark mb-4">Browser Not Supported</h1> - - <p className="text-lg text-kobo-gray mb-8"> - Unfortunately, your current browser doesn't support the technologies required to run Kobo Backup - Manager. - </p> - - {/* Missing Features */} - {missingFeatures.length > 0 && ( - <div className="bg-kobo-cream-dark rounded-lg p-6 mb-8 text-left"> - <h3 className="font-semibold text-kobo-dark mb-3">Missing Required Features:</h3> - <ul className="space-y-2"> - {missingFeatures.map((feature, index) => ( - <li key={index} className="flex items-start gap-2 text-kobo-gray"> - <svg - className="w-5 h-5 text-kobo-error shrink-0 mt-0.5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M6 18L18 6M6 6l12 12" - /> - </svg> - <span>{feature}</span> - </li> - ))} - </ul> - </div> - )} - - {/* Recommended Browsers */} - <div className="mb-8"> - <h3 className="font-semibold text-kobo-dark mb-4">Recommended Browsers</h3> - <div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> - {recommendedBrowsers.map((browser) => ( - <a - key={browser.name} - href={browser.url} - target="_blank" - rel="noopener noreferrer" - className="block p-4 bg-white rounded-lg border-2 border-kobo-gray-light hover:border-kobo-accent hover:shadow-lg transition-all" - > - <div className="text-4xl mb-2">{browser.icon}</div> - <h4 className="font-semibold text-kobo-dark">{browser.name}</h4> - <p className="text-sm text-kobo-gray">{browser.version}</p> - </a> - ))} - </div> - </div> - - {/* Help Text */} - <div className="text-sm text-kobo-gray"> - <p> - After installing a supported browser, return to this page and the app will work automatically. - </p> - </div> - </div> - </Card> - </div> - ); -} diff --git a/src/components/backup/BackupConfiguration.jsx b/src/components/backup/BackupConfiguration.jsx deleted file mode 100644 index 6d60a15..0000000 --- a/src/components/backup/BackupConfiguration.jsx +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Backup configuration - choose what to include - */ - -import { Card } from '../common/Card.tsx'; -import { Button } from '../common/Button.tsx'; -import { Checkbox } from '../common/Checkbox.tsx'; -import { Icon } from '../common/Icon.tsx'; -import { formatBytes } from '../../utils/formatters.ts'; - -export function BackupConfiguration({ - options, - onOptionsChange, - onStartBackup, - estimatedSize, - bookCount, - annotationCount, -}) { - return ( - <div className="max-w-2xl mx-auto"> - <Card> - <h2 className="text-3xl font-display text-kobo-dark mb-2 text-center">Backup Configuration</h2> - <p className="text-lg font-body text-kobo-gray mb-8 text-center"> - Choose what to include in your backup - </p> - - <div className="space-y-4 mb-8"> - <Checkbox - checked={options.includeBooks} - onChange={(checked) => onOptionsChange({ ...options, includeBooks: checked })} - label={`Book Files (${bookCount} files)`} - sublabel="Uncheck for a database-only backup (faster, smaller — useful if you only want to preserve reading progress and annotations)" - /> - - <Checkbox - checked={options.includeAnnotations} - onChange={(checked) => onOptionsChange({ ...options, includeAnnotations: checked })} - label={`Annotations & Highlights (${annotationCount})`} - sublabel="Recommended - Your notes and highlights" - /> - - <Checkbox - checked={options.includeProgress} - onChange={(checked) => onOptionsChange({ ...options, includeProgress: checked })} - label="Reading Progress" - sublabel="Recommended - Track where you left off" - /> - - <Checkbox - checked={options.includeSettings} - onChange={(checked) => onOptionsChange({ ...options, includeSettings: checked })} - label="Device Settings" - sublabel="Optional - May not work on different models" - /> - </div> - - {/* Storage Estimate */} - <div className="p-4 bg-kobo-info/10 border border-kobo-info/20 rounded-lg mb-8"> - <div className="flex items-center gap-3"> - <Icon type="info" className="text-kobo-info shrink-0" /> - <div> - <p className="font-semibold font-body text-kobo-dark"> - Estimated backup size: {formatBytes(estimatedSize)} - </p> - <p className="text-sm font-body text-kobo-gray"> - Make sure you have enough disk space available - </p> - </div> - </div> - </div> - - {/* Start Button */} - <Button size="lg" variant="primary" onClick={onStartBackup} className="w-full"> - <Icon type="download" size={20} /> - Start Backup - </Button> - </Card> - </div> - ); -} diff --git a/src/components/backup/BackupProgress.jsx b/src/components/backup/BackupProgress.jsx deleted file mode 100644 index 537ff34..0000000 --- a/src/components/backup/BackupProgress.jsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Backup progress screen with circular progress - */ - -import { Card } from '../common/Card.tsx'; -import { CircularProgress } from '../common/CircularProgress.tsx'; -import { Icon } from '../common/Icon.tsx'; -import { Button } from '../common/Button.tsx'; - -export function BackupProgress({ progress, error, onRetry }) { - const { stage, percent, filesProcessed, totalFiles } = progress; - - // Error state — show clearly instead of silently going back - if (error) { - return ( - <div className="max-w-2xl mx-auto"> - <Card className="text-center"> - <div className="flex justify-center mb-6"> - <div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center"> - <Icon type="alert" size={32} className="text-red-500" /> - </div> - </div> - <h2 className="text-2xl font-display text-kobo-dark mb-3">Backup Failed</h2> - <p className="text-kobo-gray mb-2 text-sm">The backup could not be completed. Error details:</p> - <div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 text-left"> - <p className="text-red-800 text-sm font-mono wrap-break-word">{error}</p> - </div> - <p className="text-kobo-gray text-sm mb-6"> - If you have a large library (>2 GB), try closing other browser tabs to free memory, then retry. - Open the browser console (F12) for more details. - </p> - <Button onClick={onRetry}>Try Again</Button> - </Card> - </div> - ); - } - - return ( - <div className="max-w-2xl mx-auto"> - <Card className="text-center"> - <h2 className="text-3xl font-display text-kobo-dark mb-8">Creating Backup</h2> - - {/* Circular Progress */} - <div className="mb-8 flex justify-center"> - <CircularProgress percent={percent} size={200} /> - </div> - - {/* Current Stage */} - <p className="text-lg font-body text-kobo-dark mb-2">{stage || 'Processing...'}</p> - - {/* File counter */} - {filesProcessed !== undefined && totalFiles > 0 && ( - <div className="mb-8 text-center"> - <p className="text-sm font-body text-kobo-gray mb-1">Files</p> - <p className="text-lg font-body text-kobo-dark"> - {filesProcessed} / {totalFiles} - </p> - </div> - )} - - {/* Stages List */} - <div className="space-y-3 mb-8"> - <StageItem - text="Reading book files" - status={percent >= 20 ? 'completed' : percent > 0 ? 'active' : 'pending'} - /> - <StageItem - text="Streaming books to archive" - status={percent >= 82 ? 'completed' : percent > 20 ? 'active' : 'pending'} - /> - <StageItem - text="Adding metadata" - status={percent >= 92 ? 'completed' : percent > 82 ? 'active' : 'pending'} - /> - <StageItem - text="Saving backup" - status={percent >= 100 ? 'completed' : percent > 92 ? 'active' : 'pending'} - /> - </div> - - {/* Warning */} - <div className="p-4 bg-kobo-warning/10 border border-kobo-warning/20 rounded-lg"> - <div className="flex items-center gap-3"> - <Icon type="alert" className="text-kobo-warning shrink-0" /> - <p className="text-sm font-body text-kobo-gray text-left"> - Keep this window open until the backup is complete - </p> - </div> - </div> - </Card> - </div> - ); -} - -function StageItem({ text, status }) { - return ( - <div className={`flex items-center gap-3 ${status === 'pending' ? 'opacity-50' : ''}`}> - {status === 'completed' && <Icon type="check" size={20} className="text-kobo-success" />} - {status === 'active' && ( - <div className="w-5 h-5 border-2 border-kobo-accent border-t-transparent rounded-full animate-spin" /> - )} - {status === 'pending' && ( - <div className="w-5 h-5 border-2 border-kobo-gray-light rounded-full opacity-30" /> - )} - <span className="font-body text-base text-kobo-dark">{text}</span> - </div> - ); -} diff --git a/src/components/backup/BackupSuccess.jsx b/src/components/backup/BackupSuccess.jsx deleted file mode 100644 index 1c82fb3..0000000 --- a/src/components/backup/BackupSuccess.jsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Backup success screen - */ - -import { Card } from '../common/Card.tsx'; -import { Button } from '../common/Button.tsx'; -import { Icon } from '../common/Icon.tsx'; -import { formatBytes } from '../../utils/formatters.ts'; - -export function BackupSuccess({ result, onCreateAnother, onDone }) { - return ( - <div className="max-w-3xl mx-auto"> - <Card className="text-center"> - {/* Success Animation */} - <div className="w-24 h-24 bg-kobo-success/10 rounded-full flex items-center justify-center mx-auto mb-6 animate-bounce"> - <Icon type="check" size={48} className="text-kobo-success" /> - </div> - - <h2 className="text-4xl font-display text-kobo-dark mb-4">Backup Complete!</h2> - - <p className="text-lg font-body text-kobo-gray mb-8"> - Your Kobo library has been successfully backed up - </p> - - {/* Summary Cards */} - <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-8"> - <SummaryCard icon="file" label="Filename" value={result.filename} truncate /> - <SummaryCard icon="storage" label="Size" value={formatBytes(result.size)} /> - <SummaryCard icon="book" label="Books" value={result.metadata?.statistics?.totalBooks || 0} /> - <SummaryCard - icon="note" - label="Annotations" - value={result.metadata?.statistics?.totalAnnotations || 0} - /> - </div> - - {/* Download Info */} - <div className="p-4 bg-kobo-success/10 border border-kobo-success/20 rounded-lg mb-8"> - <div className="flex items-start gap-3"> - <Icon type="download" className="text-kobo-success shrink-0 mt-1" /> - <div className="text-left flex-1"> - <p className="font-semibold font-body text-kobo-dark mb-1"> - Backup saved to your Downloads folder - </p> - <p className="text-sm font-body text-kobo-gray">Look for: {result.filename}</p> - </div> - </div> - </div> - - {/* Next Steps */} - <div className="text-left mb-8 p-6 bg-kobo-cream-dark rounded-lg"> - <h3 className="font-display text-kobo-dark mb-4 flex items-center gap-2"> - <Icon type="info" size={20} /> - Recommended Next Steps - </h3> - <ul className="space-y-3"> - <li className="flex items-start gap-3"> - <span className="text-kobo-accent">☁️</span> - <div> - <p className="font-medium font-body text-kobo-dark">Upload to cloud storage</p> - <p className="text-sm font-body text-kobo-gray">Google Drive, Dropbox, or OneDrive</p> - </div> - </li> - <li className="flex items-start gap-3"> - <span className="text-kobo-accent">💾</span> - <div> - <p className="font-medium font-body text-kobo-dark">Copy to external drive</p> - <p className="text-sm font-body text-kobo-gray">Keep a physical backup for safety</p> - </div> - </li> - <li className="flex items-start gap-3"> - <span className="text-kobo-accent">🔒</span> - <div> - <p className="font-medium font-body text-kobo-dark">Store in multiple locations</p> - <p className="text-sm font-body text-kobo-gray"> - 3-2-1 backup rule: 3 copies, 2 different media, 1 offsite - </p> - </div> - </li> - </ul> - </div> - - {/* Action Buttons */} - <div className="flex flex-col sm:flex-row gap-4 justify-center"> - <Button size="lg" variant="secondary" onClick={onCreateAnother}> - Create Another Backup - </Button> - <Button size="lg" variant="primary" onClick={onDone}> - Done - </Button> - </div> - </Card> - </div> - ); -} - -function SummaryCard({ icon, label, value, truncate }) { - return ( - <div className="p-4 bg-white rounded-lg border-2 border-kobo-cream-dark"> - <Icon type={icon} size={24} className="text-kobo-accent mx-auto mb-2" /> - <p className="text-xs font-body text-kobo-gray mb-1">{label}</p> - <p className={`text-base font-body text-kobo-dark ${truncate ? 'truncate' : ''}`}>{value}</p> - </div> - ); -} diff --git a/src/components/backup/BackupWizard.jsx b/src/components/backup/BackupWizard.jsx deleted file mode 100644 index 70ebfe3..0000000 --- a/src/components/backup/BackupWizard.jsx +++ /dev/null @@ -1,200 +0,0 @@ -/** - * Backup wizard — orchestrates the entire backup flow. - */ - -import { useState } from 'react'; -import { useFileSystem } from '../../hooks/useFileSystem.ts'; -import { useKoboDevice } from '../../hooks/useKoboDevice.ts'; -import { useBackup } from '../../hooks/useBackup.ts'; -import { useKoboStore } from '../../stores/koboStore.ts'; -import { generateBackupFilename } from '../../utils/backup.ts'; -import { directorySource } from '../../utils/deviceSource.ts'; -import { Container } from '../layout/Container.tsx'; - -import { DeviceSelector } from './DeviceSelector.jsx'; -import { ScanningScreen } from './ScanningScreen.jsx'; -import { LibraryOverview } from './LibraryOverview.jsx'; -import { BackupConfiguration } from './BackupConfiguration.jsx'; -import { BackupProgress } from './BackupProgress.jsx'; -import { BackupSuccess } from './BackupSuccess.jsx'; - -export function BackupWizard({ onComplete }) { - const [step, setStep] = useState('select'); // select | scanning | overview | configure | progress | success - const [backupOptions, setBackupOptions] = useState({ - includeBooks: true, - includeAnnotations: true, - includeProgress: true, - includeSettings: false, - }); - const [backupError, setBackupError] = useState(null); - - const fileSystem = useFileSystem(); - const koboDevice = useKoboDevice(); - const backup = useBackup(); - const addBackup = useKoboStore((state) => state.addBackup); - - const setDevice = useKoboStore((state) => state.setDevice); - const setBooks = useKoboStore((state) => state.setBooks); - const setAnnotations = useKoboStore((state) => state.setAnnotations); - const setStats = useKoboStore((state) => state.setStats); - const setSource = useKoboStore((state) => state.setSource); - - const [koboData, setKoboData] = useState(null); - - // Step 1: Select device - const handleSelectDevice = async () => { - const dirHandle = await fileSystem.selectDirectory(); - if (dirHandle) { - setStep('scanning'); - try { - const source = directorySource(dirHandle); - const data = await koboDevice.scanDevice(source); - setKoboData(data); - - // Populate global store - setDevice(data.deviceInfo); - setBooks(data.books); - setAnnotations(data.annotations); - setStats(data.stats); - setSource(source); - - setStep('overview'); - } catch (error) { - console.error('Scan failed:', error); - setStep('select'); - } - } - }; - - // Step 2: After overview, go to configuration - const handleContinueToConfig = () => setStep('configure'); - - // Step 3: Start backup - // - // CRITICAL: showSaveFilePicker must be called BEFORE any await inside this - // function. Chrome requires a synchronous user-gesture chain — any await - // before the picker call breaks that chain and throws SecurityError. - // The picker IS async (it shows a dialog), but calling await on it from - // within a synchronous click handler preserves the gesture context. - const handleStartBackup = async () => { - const suggestedFilename = generateBackupFilename(); - - // Try to get a writable file handle via the streaming-friendly API. - let writableFileHandle = null; - if ('showSaveFilePicker' in window) { - try { - writableFileHandle = await window.showSaveFilePicker({ - suggestedName: suggestedFilename, - startIn: 'downloads', - types: [{ description: 'Kobo Backup Archive', accept: { 'application/zip': ['.zip'] } }], - }); - } catch (err) { - if (err.name === 'AbortError') { - // User dismissed the dialog — stay on configure screen - return; - } - // Other error (e.g. browser restriction) — fall through to blob path - console.warn('[BACKUP] showSaveFilePicker failed, using blob fallback:', err.message); - } - } - - setStep('progress'); - setBackupError(null); - - try { - const result = await backup.create(koboData, { - ...backupOptions, - writableFileHandle, - suggestedFilename, - }); - - if (result) { - addBackup({ - filename: result.filename, - created: new Date().toISOString(), - size: result.size, - deviceModel: koboData.deviceInfo?.model || 'Unknown', - bookCount: koboData.books?.length || 0, - annotationCount: koboData.annotations?.length || 0, - }); - setStep('success'); - } else { - setStep('configure'); - } - } catch (error) { - console.error('Backup failed:', error); - // Stay on progress screen and show the error there instead of - // silently returning to configure (which gave users zero feedback). - setBackupError(error.message || 'An unexpected error occurred during backup.'); - } - }; - - const handleRetryAfterError = () => { - setBackupError(null); - backup.reset(); - setStep('configure'); - }; - - // Step 4: Create another or done - const handleCreateAnother = () => { - fileSystem.clearDirectory(); - koboDevice.clearDevice(); - backup.reset(); - setKoboData(null); - setBackupError(null); - setStep('select'); - }; - - const handleDone = () => { - if (onComplete) { - onComplete(); - } else { - handleCreateAnother(); - } - }; - - return ( - <div className="min-h-screen py-12"> - <Container className="max-w-6xl"> - {step === 'select' && ( - <DeviceSelector - onSelectDevice={handleSelectDevice} - isSelecting={fileSystem.isSelecting} - error={fileSystem.error || koboDevice.error} - /> - )} - - {step === 'scanning' && <ScanningScreen scanProgress={koboDevice.scanProgress} />} - - {step === 'overview' && koboData && ( - <LibraryOverview - books={koboData.books} - annotations={koboData.annotations} - stats={koboData.stats} - estimatedSize={backup.estimateSize(koboData)} - onContinue={handleContinueToConfig} - /> - )} - - {step === 'configure' && koboData && ( - <BackupConfiguration - options={backupOptions} - onOptionsChange={setBackupOptions} - onStartBackup={handleStartBackup} - estimatedSize={backup.estimateSize(koboData)} - bookCount={koboData.books?.length || 0} - annotationCount={koboData.annotations?.length || 0} - /> - )} - - {step === 'progress' && ( - <BackupProgress progress={backup.progress} error={backupError} onRetry={handleRetryAfterError} /> - )} - - {step === 'success' && backup.result && ( - <BackupSuccess result={backup.result} onCreateAnother={handleCreateAnother} onDone={handleDone} /> - )} - </Container> - </div> - ); -} diff --git a/src/components/backup/CoverPreview.jsx b/src/components/backup/CoverPreview.jsx deleted file mode 100644 index 7b4906e..0000000 --- a/src/components/backup/CoverPreview.jsx +++ /dev/null @@ -1,120 +0,0 @@ -import { useState, useEffect } from 'react'; -import { getCoverUrl } from '../../utils/koboCovers.ts'; - -// Simple hash function to generate consistent color palettes per book title -function getBookColors(title = '') { - let hash = 0; - for (let i = 0; i < title.length; i++) { - hash = title.charCodeAt(i) + ((hash << 5) - hash); - } - - const hue = Math.abs(hash % 360); - const saturation = 45 + Math.abs((hash >> 8) % 25); // 45% - 70% - const lightness = 25 + Math.abs((hash >> 16) % 20); // 25% - 45% (sleek dark colors) - - return { - bg: `hsl(${hue}, ${saturation}%, ${lightness}%)`, - bgLight: `hsl(${hue}, ${saturation}%, ${lightness + 15}%)`, - accent: `hsl(${(hue + 40) % 360}, ${saturation + 10}%, 75%)`, - text: '#ffffff', - }; -} - -export function CoverPreview({ source, coverId, title = 'Untitled', author = 'Unknown', className = '' }) { - const [coverUrl, setCoverUrl] = useState(null); - const [loading, setLoading] = useState(true); - const colors = getBookColors(title); - - useEffect(() => { - let active = true; - let url = null; - - async function loadCover() { - if (!source || !coverId) { - setLoading(false); - return; - } - - try { - url = await getCoverUrl(source, coverId); - if (active) { - setCoverUrl(url); - } else if (url) { - // Component unmounted / deps changed while loading — don't leak the URL. - URL.revokeObjectURL(url); - } - } catch (err) { - console.error('[CoverPreview] Failed to load cover:', err); - } finally { - if (active) { - setLoading(false); - } - } - } - - loadCover(); - - return () => { - active = false; - if (url) { - URL.revokeObjectURL(url); - } - }; - }, [source, coverId]); - - const wrapperClass = `relative aspect-3/4 rounded-md shadow-md overflow-hidden transition-all duration-300 group-hover:shadow-lg group-hover:scale-[1.03] select-none ${className}`; - - if (coverUrl && !loading) { - return ( - <div className={wrapperClass}> - {/* Real book spine effect */} - <div className="absolute top-0 left-0 w-[4px] h-full bg-black/25 z-10" /> - <div className="absolute top-0 left-[4px] w-[2px] h-full bg-white/10 z-10" /> - <img src={coverUrl} alt={title} className="w-full h-full object-cover" loading="lazy" /> - {/* Soft overlay shadow to look like a book */} - <div className="absolute inset-0 bg-linear-to-r from-black/20 via-transparent to-black/10 mix-blend-multiply pointer-events-none" /> - </div> - ); - } - - // Beautiful SVG generated cover fallback - return ( - <div - className={wrapperClass} - style={{ - background: `linear-gradient(135deg, ${colors.bgLight} 0%, ${colors.bg} 100%)`, - }} - > - {/* Book spine elements */} - <div className="absolute top-0 left-0 w-[6px] h-full bg-black/20 z-10" /> - <div className="absolute top-0 left-[6px] w-[2px] h-full bg-white/10 z-10" /> - - <div className="absolute inset-0 flex flex-col justify-between p-4 z-0 text-left select-none text-white"> - {/* Decorative elements */} - <div className="h-[2px] w-12 rounded-full" style={{ backgroundColor: colors.accent }} /> - - {/* Typography */} - <div className="grow flex flex-col justify-center my-4 overflow-hidden"> - <h4 className="font-display font-bold text-sm sm:text-base leading-tight tracking-wide line-clamp-3 mb-2"> - {title} - </h4> - <span - className="text-xs font-semibold tracking-wider uppercase opacity-75 line-clamp-2" - style={{ color: colors.accent }} - > - {author} - </span> - </div> - - {/* Bottom decorative bar */} - <div className="flex justify-between items-center text-[10px] opacity-50 uppercase tracking-widest font-mono"> - <span>Kobo Edition</span> - <span>●</span> - </div> - </div> - - {/* Real book shadow overlay */} - <div className="absolute inset-0 bg-linear-to-r from-black/20 via-transparent to-black/10 mix-blend-multiply pointer-events-none" /> - </div> - ); -} diff --git a/src/components/backup/DeviceSelector.jsx b/src/components/backup/DeviceSelector.jsx deleted file mode 100644 index 8bc5b38..0000000 --- a/src/components/backup/DeviceSelector.jsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Device selector component with connection instructions - */ - -import { Card } from '../common/Card.tsx'; -import { Button } from '../common/Button.tsx'; -import { Icon } from '../common/Icon.tsx'; - -export function DeviceSelector({ onSelectDevice, isSelecting, error }) { - return ( - <div className="max-w-4xl mx-auto"> - <Card className="text-center"> - <div className="mb-8"> - <div className="w-20 h-20 bg-kobo-accent/10 rounded-full flex items-center justify-center mx-auto mb-4"> - <Icon type="device" size="lg" className="text-kobo-accent" /> - </div> - <h2 className="text-3xl font-display text-kobo-dark mb-2">Connect Your Kobo</h2> - <p className="text-lg font-body text-kobo-gray">Follow these steps to connect your device</p> - </div> - - {/* Connection Steps */} - <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> - <div className="p-4 bg-kobo-cream-dark rounded-lg"> - <div className="w-12 h-12 bg-kobo-accent text-white rounded-full flex items-center justify-center mx-auto mb-3 font-body font-bold text-xl"> - 1 - </div> - <h3 className="font-semibold font-display text-kobo-dark mb-2">Plug in USB</h3> - <p className="text-sm font-body text-kobo-gray"> - Connect your Kobo to your computer using the USB cable - </p> - </div> - - <div className="p-4 bg-kobo-cream-dark rounded-lg"> - <div className="w-12 h-12 bg-kobo-accent text-white rounded-full flex items-center justify-center mx-auto mb-3 font-body font-bold text-xl"> - 2 - </div> - <h3 className="font-semibold font-display text-kobo-dark mb-2">Unlock Device</h3> - <p className="text-sm font-body text-kobo-gray"> - Tap "Connect" on your Kobo screen when prompted - </p> - </div> - - <div className="p-4 bg-kobo-cream-dark rounded-lg"> - <div className="w-12 h-12 bg-kobo-accent text-white rounded-full flex items-center justify-center mx-auto mb-3 font-body font-bold text-xl"> - 3 - </div> - <h3 className="font-semibold font-display text-kobo-dark mb-2">Select Device</h3> - <p className="text-sm font-body text-kobo-gray"> - Click the button below to select your Kobo drive - </p> - </div> - </div> - - {/* Error Message */} - {error && ( - <div className="mb-6 p-4 bg-kobo-error/10 border-2 border-kobo-error/20 rounded-lg"> - <div className="flex items-start gap-3"> - <Icon type="alert" className="text-kobo-error shrink-0" /> - <div className="text-left"> - <h4 className="font-semibold font-display text-kobo-error mb-1">{error.title}</h4> - <p className="text-sm font-body text-kobo-gray">{error.message}</p> - </div> - </div> - </div> - )} - - {/* Select Button */} - <Button - size="lg" - variant="primary" - onClick={onSelectDevice} - loading={isSelecting} - className="w-full md:w-auto" - > - <Icon type="folder" size={20} /> - {isSelecting ? 'Waiting for selection...' : 'Select Kobo Device'} - </Button> - - {/* Help Text */} - <div className="mt-6 text-sm font-body text-kobo-gray"> - <p>Your Kobo should appear as a drive named "KOBOeReader"</p> - </div> - </Card> - </div> - ); -} diff --git a/src/components/backup/LibraryOverview.jsx b/src/components/backup/LibraryOverview.jsx deleted file mode 100644 index 15d5041..0000000 --- a/src/components/backup/LibraryOverview.jsx +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Library overview showing statistics after scan - */ - -import { Card } from '../common/Card.tsx'; -import { Button } from '../common/Button.tsx'; -import { Icon } from '../common/Icon.tsx'; -import { formatBytes, formatDuration } from '../../utils/formatters.ts'; - -export function LibraryOverview({ books, annotations, stats, estimatedSize, onContinue }) { - return ( - <div className="max-w-4xl mx-auto space-y-6"> - <div className="text-center mb-8"> - <h2 className="text-3xl font-display text-kobo-dark mb-2">Your Library</h2> - <p className="text-lg font-body text-kobo-gray">Here's what we found on your Kobo</p> - </div> - - {/* Statistics Cards */} - <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"> - <StatCard - icon="book" - value={stats?.totalBooks || books?.length || 0} - label="Books Found" - color="blue" - /> - <StatCard - icon="note" - value={stats?.totalAnnotations || annotations?.length || 0} - label="Annotations" - color="green" - /> - <StatCard icon="storage" value={formatBytes(estimatedSize)} label="Estimated Size" color="purple" /> - <StatCard icon="chart" value={`${stats?.booksFinished || 0}`} label="Books Finished" color="orange" /> - </div> - - {/* Reading Stats */} - {stats && ( - <Card> - <h3 className="text-xl font-display text-kobo-dark mb-4">Reading Statistics</h3> - <div className="grid grid-cols-2 sm:grid-cols-3 gap-4"> - <div> - <p className="text-sm font-body text-kobo-gray mb-1">Books Started</p> - <p className="text-2xl font-display text-kobo-dark">{stats.booksStarted}</p> - </div> - <div> - <p className="text-sm font-body text-kobo-gray mb-1">Currently Reading</p> - <p className="text-2xl font-display text-kobo-dark">{stats.currentlyReading}</p> - </div> - <div> - <p className="text-sm font-body text-kobo-gray mb-1">Time Spent Reading</p> - <p className="text-2xl font-display text-kobo-dark">{formatDuration(stats.totalMinutesRead)}</p> - </div> - </div> - </Card> - )} - - {/* Recently Read Books Preview */} - {books && books.length > 0 && ( - <Card> - <h3 className="text-xl font-display text-kobo-dark mb-4">Recently Read Books</h3> - <div className="space-y-3"> - {books.slice(0, 5).map((book, index) => ( - <div - key={book.ContentID || index} - className="flex items-center gap-4 p-3 bg-kobo-cream-dark rounded-lg" - > - <div className="flex-1"> - <h4 className="font-semibold font-body text-kobo-dark truncate">{book.Title}</h4> - <p className="text-sm font-body text-kobo-gray truncate">{book.Author}</p> - </div> - <div className="text-right"> - <div className="text-sm font-semibold text-kobo-accent">{book.Progress}%</div> - </div> - </div> - ))} - </div> - {books.length > 5 && ( - <p className="text-sm font-body text-kobo-gray mt-3 text-center"> - And {books.length - 5} more books... - </p> - )} - </Card> - )} - - {/* Action Buttons */} - <div className="flex gap-4 justify-center"> - <Button size="lg" variant="primary" onClick={onContinue} className="w-full sm:w-auto"> - <Icon type="download" size={20} /> - Create Backup Now - </Button> - </div> - </div> - ); -} - -function StatCard({ icon, value, label, color }) { - const colors = { - blue: 'bg-kobo-info/10 text-kobo-info', - green: 'bg-kobo-success/10 text-kobo-success', - purple: 'bg-kobo-accent/10 text-kobo-accent', - orange: 'bg-kobo-warning/10 text-kobo-warning', - }; - - const colorClass = colors[color] || colors.blue; - - return ( - <Card className="text-center"> - <div className={`w-12 h-12 ${colorClass} rounded-full flex items-center justify-center mx-auto mb-3`}> - <Icon type={icon} size={24} /> - </div> - <div className="text-2xl font-display text-kobo-dark mb-1">{value}</div> - <div className="text-sm font-body text-kobo-gray">{label}</div> - </Card> - ); -} diff --git a/src/components/backup/ScanningScreen.jsx b/src/components/backup/ScanningScreen.jsx deleted file mode 100644 index 1c6b6ea..0000000 --- a/src/components/backup/ScanningScreen.jsx +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Scanning screen showing progress while reading Kobo device - */ - -import { Card } from '../common/Card.tsx'; -import { ProgressBar } from '../common/ProgressBar.tsx'; -import { Icon } from '../common/Icon.tsx'; - -export function ScanningScreen({ scanProgress }) { - const { current, total } = scanProgress; - const stage = [ - 'Preparing...', - 'Reading database...', - 'Analyzing books...', - 'Finding book files...', - 'Scan complete', - ][current]; - - const percent = total > 0 ? (current / total) * 100 : 0; - - return ( - <div className="max-w-2xl mx-auto"> - <Card className="text-center"> - {/* Animated Loader */} - <div className="mb-8"> - <div className="w-24 h-24 mx-auto mb-4"> - <div className="relative"> - <div className="w-24 h-24 border-8 border-kobo-cream-dark rounded-full"></div> - <div className="absolute top-0 left-0 w-24 h-24 border-8 border-kobo-accent rounded-full border-t-transparent animate-spin"></div> - </div> - </div> - - <h2 className="text-2xl font-display text-kobo-dark mb-2">Scanning Your Kobo</h2> - <p className="text-lg font-body text-kobo-gray">{stage || 'Preparing...'}</p> - </div> - - {/* Progress Bar */} - <ProgressBar percent={percent} animated={true} className="mb-8" /> - - {/* Status Details */} - <div className="space-y-3"> - <StatusItem text="Device connected" status="completed" /> - <StatusItem - text="Reading database" - status={current >= 1 ? 'completed' : current === 0 ? 'pending' : 'active'} - /> - <StatusItem - text="Analyzing books" - status={current >= 2 ? 'completed' : current < 1 ? 'pending' : 'active'} - /> - <StatusItem - text="Finding book files" - status={current >= 3 ? 'completed' : current < 2 ? 'pending' : 'active'} - /> - <StatusItem text="Scan complete" status={current >= 4 ? 'completed' : 'pending'} /> - </div> - - {/* Info Message */} - <div className="mt-8 p-4 bg-kobo-info/10 rounded-lg"> - <p className="text-sm font-body text-kobo-gray"> - This may take a moment depending on your library size - </p> - </div> - </Card> - </div> - ); -} - -function StatusItem({ text, status }) { - const statusColors = { - completed: 'bg-kobo-success', - active: 'bg-kobo-accent', - pending: 'bg-kobo-gray-light', - }; - - const colorClass = statusColors[status] || statusColors.pending; - - return ( - <div className={`flex items-center gap-3 ${status === 'pending' ? 'opacity-50' : ''}`}> - {status === 'completed' ? ( - <Icon type="check" size={20} className="text-kobo-success" /> - ) : ( - <div className={`w-5 h-5 rounded-full ${colorClass} ${status === 'active' ? 'animate-pulse' : ''}`} /> - )} - <span className="font-body text-kobo-dark">{text}</span> - </div> - ); -} diff --git a/src/components/common/Alert.tsx b/src/components/common/Alert.tsx new file mode 100644 index 0000000..8af6ccd --- /dev/null +++ b/src/components/common/Alert.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from 'react'; +import { Icon, type IconType } from './Icon.tsx'; +import { errorText, useI18n } from '../../i18n/core.ts'; +import type { UiError } from '../../types/kobo.ts'; + +export type AlertTone = 'info' | 'success' | 'warning' | 'error'; + +const TONES: Record<AlertTone, { box: string; icon: IconType; iconClass: string }> = { + info: { box: 'bg-kobo-info/10 border-kobo-info/30', icon: 'info', iconClass: 'text-kobo-info' }, + success: { box: 'bg-kobo-success/15 border-kobo-success/40', icon: 'check', iconClass: 'text-kobo-dark' }, + warning: { box: 'bg-kobo-warning/15 border-kobo-warning/50', icon: 'alert', iconClass: 'text-kobo-dark' }, + error: { box: 'bg-kobo-error/10 border-kobo-error/40', icon: 'alert', iconClass: 'text-kobo-error' }, +}; + +export interface AlertProps { + tone?: AlertTone; + title?: ReactNode; + children?: ReactNode; + className?: string; + /** Announce to screen readers when it appears. */ + live?: boolean; +} + +export function Alert({ tone = 'info', title, children, className = '', live = false }: AlertProps) { + const t = TONES[tone]; + return ( + <div + className={`flex items-start gap-3 rounded-lg border p-4 text-left ${t.box} ${className}`} + role={live ? (tone === 'error' ? 'alert' : 'status') : undefined} + > + <Icon type={t.icon} className={`mt-0.5 shrink-0 ${t.iconClass}`} /> + <div className="min-w-0 flex-1 text-sm text-kobo-dark"> + {title && <p className="mb-1 font-semibold">{title}</p>} + {children} + </div> + </div> + ); +} + +/** A localized error with the technical message tucked into <details>. */ +export function ErrorAlert({ + error, + title, + children, +}: { + error: UiError; + title?: string; + children?: ReactNode; +}) { + const i18n = useI18n(); + const text = errorText(i18n, error); + return ( + <Alert tone="error" title={title} live> + <p>{text}</p> + {text !== error.message && ( + <details className="mt-2"> + <summary className="cursor-pointer text-xs text-kobo-gray"> + {i18n.m.common.technicalDetails} + </summary> + <p className="mt-1 break-words font-mono text-xs text-kobo-gray"> + {error.code}: {error.message} + </p> + </details> + )} + {children} + </Alert> + ); +} diff --git a/src/components/common/Checkbox.tsx b/src/components/common/Checkbox.tsx index 362f1bb..70bf7aa 100644 --- a/src/components/common/Checkbox.tsx +++ b/src/components/common/Checkbox.tsx @@ -9,6 +9,10 @@ export interface CheckboxProps { className?: string; } +/** + * Native checkbox with a custom look. The sublabel is linked with + * aria-describedby (kept out of the label so the accessible name stays short). + */ export function Checkbox({ checked = false, onChange, @@ -19,49 +23,44 @@ export function Checkbox({ }: CheckboxProps) { const descriptionId = useId(); return ( - <label - className={`flex items-start gap-2 group ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'} ${className}`} - > - <span className="relative flex items-center justify-center mt-1"> - <input - type="checkbox" - checked={checked} - onChange={(e) => !disabled && onChange?.(e.target.checked)} - disabled={disabled} - aria-describedby={sublabel ? descriptionId : undefined} - className="sr-only peer" - /> - <span - aria-hidden="true" - className={`w-5 h-5 rounded-sm border-2 transition-all peer-focus-visible:ring-2 peer-focus-visible:ring-kobo-accent peer-focus-visible:ring-offset-2 ${ - checked - ? 'bg-kobo-accent border-kobo-accent' - : 'bg-white border-kobo-gray-light group-hover:border-kobo-accent' - }`} - > - {checked && ( - <svg - className="w-full h-full text-kobo-dark p-0.5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> - </svg> - )} - </span> - </span> - - {(label || sublabel) && ( - <span className="flex-1"> - {label && <span className="block text-base font-medium text-kobo-dark">{label}</span>} - {sublabel && ( - <span id={descriptionId} className="block text-sm text-kobo-gray mt-1"> - {sublabel} - </span> - )} + <div className={`${disabled ? 'opacity-50' : ''} ${className}`}> + <label className={`group flex items-start gap-2 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}> + <span className="relative mt-1 flex items-center justify-center"> + <input + type="checkbox" + checked={checked} + onChange={(e) => !disabled && onChange?.(e.target.checked)} + disabled={disabled} + aria-describedby={sublabel ? descriptionId : undefined} + className="peer sr-only" + /> + <span + aria-hidden="true" + className={`h-5 w-5 rounded-sm border-2 transition-all peer-focus-visible:ring-2 peer-focus-visible:ring-kobo-accent peer-focus-visible:ring-offset-2 ${ + checked + ? 'border-kobo-accent bg-kobo-accent' + : 'border-kobo-gray-light bg-white group-hover:border-kobo-accent' + }`} + > + {checked && ( + <svg + className="h-full w-full p-0.5 text-kobo-dark" + fill="none" + stroke="currentColor" + viewBox="0 0 24 24" + > + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /> + </svg> + )} + </span> </span> + {label && <span className="flex-1 text-base font-medium text-kobo-dark">{label}</span>} + </label> + {sublabel && ( + <p id={descriptionId} className="ml-7 mt-1 text-sm text-kobo-gray"> + {sublabel} + </p> )} - </label> + </div> ); } diff --git a/src/components/common/PageHeader.tsx b/src/components/common/PageHeader.tsx new file mode 100644 index 0000000..afadfb5 --- /dev/null +++ b/src/components/common/PageHeader.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; + +export function PageHeader({ + title, + subtitle, + children, +}: { + title: string; + subtitle?: ReactNode; + children?: ReactNode; +}) { + return ( + <header className="mb-8 text-center"> + <h1 className="mb-3 text-4xl text-kobo-dark sm:text-5xl">{title}</h1> + {subtitle && <p className="mx-auto max-w-2xl text-lg text-kobo-gray">{subtitle}</p>} + {children} + </header> + ); +} diff --git a/src/components/common/ProgressBar.tsx b/src/components/common/ProgressBar.tsx index de98047..7962037 100644 --- a/src/components/common/ProgressBar.tsx +++ b/src/components/common/ProgressBar.tsx @@ -1,5 +1,3 @@ -import { formatProgress } from '../../utils/formatters.ts'; - export interface ProgressBarProps { percent?: number; label?: string; @@ -15,7 +13,7 @@ export function ProgressBar({ percent = 0, label, showLabel = false, className = {(showLabel || label) && ( <div className="flex justify-between items-center mb-2"> <span className="text-sm text-kobo-gray">{label}</span> - <span className="text-sm font-semibold text-kobo-dark">{formatProgress(value)}</span> + <span className="text-sm font-semibold text-kobo-dark">{Math.round(value)}%</span> </div> )} <div diff --git a/src/components/common/Stepper.tsx b/src/components/common/Stepper.tsx new file mode 100644 index 0000000..3ed37bf --- /dev/null +++ b/src/components/common/Stepper.tsx @@ -0,0 +1,42 @@ +import { useI18n } from '../../i18n/core.ts'; +import { Icon } from './Icon.tsx'; + +/** Wizard progress indicator (ordered list with aria-current). */ +export function Stepper({ steps, current }: { steps: string[]; current: number }) { + const { m, fmt } = useI18n(); + return ( + <nav aria-label={fmt(m.common.stepOf, { current: current + 1, total: steps.length })} className="mb-8"> + <ol className="flex items-center justify-center gap-2 sm:gap-4"> + {steps.map((label, i) => { + const state = i < current ? 'done' : i === current ? 'current' : 'todo'; + return ( + <li + key={label} + className="flex items-center gap-2 sm:gap-4" + aria-current={state === 'current' ? 'step' : undefined} + > + <span className="flex items-center gap-2"> + <span + className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold ${ + state === 'todo' ? 'bg-kobo-cream-dark text-kobo-gray' : 'bg-kobo-accent text-kobo-dark' + }`} + aria-hidden="true" + > + {state === 'done' ? <Icon type="check" size="sm" /> : i + 1} + </span> + <span + className={`hidden text-sm sm:inline ${state === 'current' ? 'font-semibold text-kobo-dark' : 'text-kobo-gray'}`} + > + {label} + </span> + </span> + {i < steps.length - 1 && ( + <span className="h-0.5 w-6 bg-kobo-cream-dark sm:w-10" aria-hidden="true" /> + )} + </li> + ); + })} + </ol> + </nav> + ); +} diff --git a/src/components/common/Tabs.tsx b/src/components/common/Tabs.tsx new file mode 100644 index 0000000..b1b67b4 --- /dev/null +++ b/src/components/common/Tabs.tsx @@ -0,0 +1,84 @@ +import { useId, useRef, type KeyboardEvent, type ReactNode } from 'react'; + +export interface TabItem<T extends string> { + id: T; + label: ReactNode; +} + +/** WAI-ARIA tabs with roving focus (arrow keys, Home/End). */ +export function Tabs<T extends string>({ + tabs, + active, + onChange, + label, + children, +}: { + tabs: TabItem<T>[]; + active: T; + onChange: (id: T) => void; + label: string; + children: ReactNode; +}) { + const baseId = useId(); + const refs = useRef<(HTMLButtonElement | null)[]>([]); + + const onKeyDown = (e: KeyboardEvent, index: number) => { + const last = tabs.length - 1; + const next = { + ArrowRight: index === last ? 0 : index + 1, + ArrowLeft: index === 0 ? last : index - 1, + Home: 0, + End: last, + }[e.key]; + if (next === undefined) return; + e.preventDefault(); + onChange(tabs[next]!.id); + refs.current[next]?.focus(); + }; + + return ( + <> + <div + role="tablist" + aria-label={label} + className="mb-8 flex overflow-x-auto border-b border-kobo-cream-dark" + > + {tabs.map((tab, i) => { + const selected = tab.id === active; + return ( + <button + key={tab.id} + ref={(el) => { + refs.current[i] = el; + }} + type="button" + role="tab" + id={`${baseId}-tab-${tab.id}`} + aria-selected={selected} + aria-controls={`${baseId}-panel`} + tabIndex={selected ? 0 : -1} + onClick={() => onChange(tab.id)} + onKeyDown={(e) => onKeyDown(e, i)} + className={`-mb-px flex items-center gap-2 whitespace-nowrap border-b-2 px-5 py-3 text-sm font-semibold transition-colors focus-visible-ring ${ + selected + ? 'border-kobo-accent-ink text-kobo-dark' + : 'border-transparent text-kobo-gray hover:text-kobo-dark' + }`} + > + {tab.label} + </button> + ); + })} + </div> + <div + role="tabpanel" + id={`${baseId}-panel`} + aria-labelledby={`${baseId}-tab-${active}`} + tabIndex={0} + className="focus-visible-ring rounded-lg" + > + {children} + </div> + </> + ); +} diff --git a/src/components/components.test.tsx b/src/components/components.test.tsx new file mode 100644 index 0000000..71657d3 --- /dev/null +++ b/src/components/components.test.tsx @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { Tabs } from './common/Tabs.tsx'; +import { Checkbox } from './common/Checkbox.tsx'; +import App from '../App.tsx'; + +afterEach(cleanup); + +describe('Tabs', () => { + function Harness() { + const [active, setActive] = useState<'a' | 'b' | 'c'>('a'); + return ( + <Tabs + label="Test" + active={active} + onChange={setActive} + tabs={[ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ]} + > + <p>panel {active}</p> + </Tabs> + ); + } + + it('exposes ARIA roles and supports arrow-key navigation', () => { + render(<Harness />); + const tabs = screen.getAllByRole('tab'); + expect(tabs[0]!.getAttribute('aria-selected')).toBe('true'); + expect(tabs[1]!.getAttribute('tabindex')).toBe('-1'); + fireEvent.keyDown(tabs[0]!, { key: 'ArrowRight' }); + expect(screen.getByRole('tabpanel').textContent).toBe('panel b'); + fireEvent.keyDown(screen.getAllByRole('tab')[1]!, { key: 'End' }); + expect(screen.getByRole('tabpanel').textContent).toBe('panel c'); + fireEvent.keyDown(screen.getAllByRole('tab')[2]!, { key: 'ArrowRight' }); + expect(screen.getByRole('tabpanel').textContent).toBe('panel a'); + }); +}); + +describe('Checkbox', () => { + it('is a real, labelled checkbox with a description', () => { + const onChange = vi.fn(); + render(<Checkbox checked={false} onChange={onChange} label="Books" sublabel="All files" />); + const box = screen.getByRole('checkbox', { name: 'Books' }); + fireEvent.click(box); + expect(onChange).toHaveBeenCalledWith(true); + expect(box.getAttribute('aria-describedby')).toBeTruthy(); + }); +}); + +describe('App navigation', () => { + it('switches language and page without reloading', () => { + window.history.replaceState(null, '', '/'); + render(<App path="/" />); + fireEvent.click(screen.getByRole('link', { name: 'Leggi questa pagina in italiano' })); + expect(window.location.pathname).toBe('/it'); + expect(document.documentElement.lang).toBe('it'); + expect(document.title).toContain('KoBup'); + + fireEvent.click(screen.getAllByRole('link', { name: 'Ripristino' })[0]!); + expect(window.location.pathname).toBe('/it/ripristino'); + expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('Ripristina un backup'); + expect(document.querySelector('link[rel="canonical"]')?.getAttribute('href')).toBe( + 'https://www.kobup.org/it/ripristino', + ); + expect(document.querySelectorAll('link[rel="alternate"][hreflang]').length).toBe(3); + }); +}); diff --git a/src/components/device/ConnectDevice.tsx b/src/components/device/ConnectDevice.tsx new file mode 100644 index 0000000..c3d3cc0 --- /dev/null +++ b/src/components/device/ConnectDevice.tsx @@ -0,0 +1,100 @@ +import { useId, useRef } from 'react'; +import { useI18n } from '../../i18n/core.ts'; +import { useFeatureDetection } from '../../hooks/useFeatureDetection.ts'; +import type { useDevice } from '../../hooks/useDevice.ts'; +import { Button } from '../common/Button.tsx'; +import { Card } from '../common/Card.tsx'; +import { Icon } from '../common/Icon.tsx'; +import { ErrorAlert } from '../common/Alert.tsx'; +import { ProgressBar } from '../common/ProgressBar.tsx'; + +type Device = ReturnType<typeof useDevice>; + +/** Instructions + picker (File System Access) or folder input fallback. */ +export function ConnectDevice({ device }: { device: Device }) { + const { m } = useI18n(); + const features = useFeatureDetection(); + const inputId = useId(); + const inputRef = useRef<HTMLInputElement>(null); + + if (device.status === 'scanning') return <ScanProgress step={device.step} />; + + return ( + <Card className="mx-auto max-w-4xl text-center"> + <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-kobo-accent/15"> + <Icon type="device" size={32} className="text-kobo-accent-ink" /> + </div> + <h2 className="mb-2 text-3xl text-kobo-dark">{m.connect.title}</h2> + <p className="mb-8 text-lg text-kobo-gray">{m.connect.subtitle}</p> + + <ol className="mb-8 grid grid-cols-1 gap-4 text-left md:grid-cols-3"> + {m.connect.steps.map((step, i) => ( + <li key={step.title} className="rounded-lg bg-kobo-cream-dark p-4"> + <span + className="mb-2 flex h-9 w-9 items-center justify-center rounded-full bg-kobo-accent font-bold text-kobo-dark" + aria-hidden="true" + > + {i + 1} + </span> + <h3 className="mb-1 text-lg text-kobo-dark">{step.title}</h3> + <p className="text-sm text-kobo-gray">{step.body}</p> + </li> + ))} + </ol> + + {device.error && ( + <div className="mb-6"> + <ErrorAlert error={device.error} /> + </div> + )} + + {features.fileSystemAccess ? ( + <Button size="lg" onClick={device.connectWithPicker} loading={device.status === 'selecting'}> + <Icon type="folder" /> + {device.status === 'selecting' ? m.connect.selecting : m.connect.selectButton} + </Button> + ) : ( + <> + <input + ref={inputRef} + id={inputId} + type="file" + className="sr-only" + {...({ webkitdirectory: '', directory: '' } as Record<string, string>)} + onChange={(e) => { + if (e.target.files) void device.connectWithFiles(e.target.files); + e.target.value = ''; + }} + /> + <Button size="lg" onClick={() => inputRef.current?.click()} aria-describedby={`${inputId}-hint`}> + <Icon type="folder" /> + {m.connect.folderButton} + </Button> + <p id={`${inputId}-hint`} className="mx-auto mt-3 max-w-xl text-sm text-kobo-gray"> + {m.connect.folderHint} + </p> + </> + )} + <p className="mt-4 text-sm text-kobo-gray">{m.connect.driveHint}</p> + </Card> + ); +} + +export function ScanProgress({ step }: { step: number }) { + const { m } = useI18n(); + const total = m.scanning.steps.length; + return ( + <Card className="mx-auto max-w-2xl text-center"> + <div + className="mx-auto mb-6 h-16 w-16 animate-spin rounded-full border-8 border-kobo-cream-dark border-t-kobo-accent" + aria-hidden="true" + /> + <h2 className="mb-2 text-2xl text-kobo-dark">{m.scanning.title}</h2> + <p className="mb-6 text-kobo-gray" role="status" aria-live="polite"> + {m.scanning.steps[Math.max(0, step - 1)] ?? m.common.loading} + </p> + <ProgressBar percent={(step / total) * 100} label={m.scanning.title} className="mb-6" /> + <p className="text-sm text-kobo-gray">{m.scanning.hint}</p> + </Card> + ); +} diff --git a/src/components/layout/Footer.jsx b/src/components/layout/Footer.jsx deleted file mode 100644 index 7dcae87..0000000 --- a/src/components/layout/Footer.jsx +++ /dev/null @@ -1,113 +0,0 @@ -/** - * App footer with links and privacy info - */ - -export function Footer({ onNavigate }) { - const currentYear = new Date().getFullYear(); - - return ( - <footer className="bg-kobo-dark text-kobo-cream py-8 mt-16"> - <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> - <div className="grid grid-cols-1 md:grid-cols-3 gap-8"> - {/* About */} - <div> - <h3 className="text-lg font-display font-bold mb-3">Kobo Backup Manager</h3> - <p className="text-sm text-kobo-gray-light"> - A free, open-source tool to backup and restore your Kobo e-reader library. All processing - happens in your browser - your data never leaves your device. - </p> - </div> - - {/* Links */} - <div> - <h3 className="text-lg font-display font-bold mb-3">Resources</h3> - <ul className="space-y-2 text-sm"> - <li> - <button - onClick={() => onNavigate('guide')} - className="text-kobo-gray-light hover:text-kobo-accent transition-colors" - > - User Guide - </button> - </li> - <li> - <button - onClick={() => onNavigate('faq')} - className="text-kobo-gray-light hover:text-kobo-accent transition-colors" - > - FAQ - </button> - </li> - <li> - <button - onClick={() => onNavigate('privacy')} - className="text-kobo-gray-light hover:text-kobo-accent transition-colors" - > - Privacy Policy - </button> - </li> - <li> - <a - href="https://github.com/Fanfulla/KoboOfflineBackup" - target="_blank" - rel="noopener noreferrer" - className="text-kobo-gray-light hover:text-kobo-accent transition-colors inline-flex items-center gap-1" - > - GitHub - <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> - <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" /> - </svg> - </a> - </li> - </ul> - </div> - - {/* Privacy Badge */} - <div> - <h3 className="text-lg font-display font-bold mb-3">Privacy First</h3> - <div className="space-y-2 text-sm text-kobo-gray-light"> - <div className="flex items-start gap-2"> - <svg - className="w-5 h-5 text-kobo-success shrink-0 mt-0.5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> - </svg> - <span>No data sent to servers</span> - </div> - <div className="flex items-start gap-2"> - <svg - className="w-5 h-5 text-kobo-success shrink-0 mt-0.5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> - </svg> - <span>No account required</span> - </div> - <div className="flex items-start gap-2"> - <svg - className="w-5 h-5 text-kobo-success shrink-0 mt-0.5" - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> - </svg> - <span>Open source & auditable</span> - </div> - </div> - </div> - </div> - - <div className="mt-8 pt-8 border-t border-kobo-gray text-center text-sm text-kobo-gray-light"> - <p>© {currentYear} Kobo Backup Manager. Not affiliated with Rakuten Kobo Inc.</p> - <p className="mt-2">Made with ❤️ for book lovers everywhere</p> - </div> - </div> - </footer> - ); -} diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx new file mode 100644 index 0000000..b280b59 --- /dev/null +++ b/src/components/layout/Footer.tsx @@ -0,0 +1,65 @@ +import { Link } from '../../router/Router.tsx'; +import { useI18n } from '../../i18n/core.ts'; +import { REPO_URL } from '../../seo/meta.ts'; +import { Icon } from '../common/Icon.tsx'; + +export function Footer() { + const { m, fmt } = useI18n(); + const linkClass = + 'text-kobo-cream/80 hover:text-white underline-offset-4 hover:underline focus-visible-ring rounded-sm'; + + return ( + <footer className="mt-16 bg-kobo-dark py-10 text-kobo-cream"> + <div className="mx-auto grid max-w-7xl grid-cols-1 gap-8 px-4 sm:px-6 md:grid-cols-3 lg:px-8"> + <div> + <p className="mb-3 font-display text-lg font-bold">KoBup</p> + <p className="text-sm text-kobo-cream/80">{m.footer.about}</p> + </div> + + <nav aria-label={m.footer.resources}> + <p className="mb-3 font-display text-lg font-bold">{m.footer.resources}</p> + <ul className="space-y-2 text-sm"> + {(['guide', 'faq', 'privacy', 'history'] as const).map((id) => ( + <li key={id}> + <Link to={id} className={linkClass}> + {m.nav[id]} + </Link> + </li> + ))} + <li> + <a + href={REPO_URL} + target="_blank" + rel="noopener noreferrer" + className={`${linkClass} inline-flex items-center gap-1`} + > + {m.footer.github} + <Icon type="external" size="sm" /> + <span className="sr-only">{m.common.opensInNewTab}</span> + </a> + </li> + </ul> + </nav> + + <div> + <p className="mb-3 font-display text-lg font-bold">{m.footer.privacyTitle}</p> + <ul className="space-y-2 text-sm text-kobo-cream/80"> + {m.footer.badges.map((badge) => ( + <li key={badge} className="flex items-start gap-2"> + <Icon type="check" className="mt-0.5 shrink-0 text-kobo-success" /> + {badge} + </li> + ))} + </ul> + </div> + </div> + + <div className="mx-auto mt-8 max-w-7xl border-t border-kobo-cream/20 px-4 pt-6 text-center text-sm text-kobo-cream/70 sm:px-6 lg:px-8"> + <p suppressHydrationWarning> + © {new Date().getFullYear()} KoBup · {m.footer.disclaimer} ·{' '} + {fmt(m.footer.version, { version: __APP_VERSION__ })} + </p> + </div> + </footer> + ); +} diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.jsx deleted file mode 100644 index 023ff4c..0000000 --- a/src/components/layout/Header.jsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * App header with branding and navigation - */ - -import { useState } from 'react'; -import { useKoboStore } from '../../stores/koboStore.ts'; - -export function Header({ currentPage = 'home', onNavigate }) { - const [mobileMenuOpen, setMobileMenuOpen] = useState(false); - const books = useKoboStore((state) => state.books); - - const navItems = [ - { id: 'home', label: 'Home' }, - ...(books && books.length > 0 ? [{ id: 'dashboard', label: 'Dashboard' }] : []), - { id: 'backup', label: 'Create Backup' }, - { id: 'restore', label: 'Restore' }, - { id: 'history', label: 'History' }, - ]; - - return ( - <header className="bg-white shadow-xs sticky top-0 z-40"> - <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> - <div className="flex justify-between items-center py-4"> - {/* Logo */} - <button - onClick={() => onNavigate('home')} - className="flex items-center gap-3 hover:opacity-80 transition-opacity" - > - <div className="w-10 h-10 bg-linear-to-br from-kobo-accent to-kobo-accent-dark rounded-lg flex items-center justify-center"> - <svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" - /> - </svg> - </div> - <div className="hidden sm:block"> - <h1 className="text-xl font-display font-bold text-kobo-dark">Kobo Backup Manager</h1> - <p className="text-xs text-kobo-gray">Never lose your library again</p> - </div> - </button> - - {/* Desktop Navigation */} - <nav className="hidden md:flex items-center gap-2"> - {navItems.map((item) => ( - <button - key={item.id} - onClick={() => onNavigate(item.id)} - className={`px-4 py-2 rounded-lg font-medium transition-colors ${ - currentPage === item.id - ? 'bg-kobo-accent text-white' - : 'text-kobo-gray hover:bg-kobo-cream-dark hover:text-kobo-dark' - }`} - > - {item.label} - </button> - ))} - </nav> - - {/* Mobile menu button */} - <button - onClick={() => setMobileMenuOpen(!mobileMenuOpen)} - className="md:hidden p-2 rounded-lg hover:bg-kobo-cream-dark" - > - <svg className="w-6 h-6 text-kobo-dark" fill="none" stroke="currentColor" viewBox="0 0 24 24"> - {mobileMenuOpen ? ( - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> - ) : ( - <path - strokeLinecap="round" - strokeLinejoin="round" - strokeWidth={2} - d="M4 6h16M4 12h16M4 18h16" - /> - )} - </svg> - </button> - </div> - - {/* Mobile Navigation */} - {mobileMenuOpen && ( - <nav className="md:hidden py-4 border-t border-kobo-cream-dark animate-slide-down"> - {navItems.map((item) => ( - <button - key={item.id} - onClick={() => { - onNavigate(item.id); - setMobileMenuOpen(false); - }} - className={`block w-full text-left px-4 py-3 rounded-lg font-medium transition-colors ${ - currentPage === item.id - ? 'bg-kobo-accent text-white' - : 'text-kobo-gray hover:bg-kobo-cream-dark hover:text-kobo-dark' - }`} - > - {item.label} - </button> - ))} - </nav> - )} - </div> - </header> - ); -} diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx new file mode 100644 index 0000000..408a8f7 --- /dev/null +++ b/src/components/layout/Header.tsx @@ -0,0 +1,99 @@ +import { useState } from 'react'; +import { Link } from '../../router/Router.tsx'; +import { useRouter } from '../../router/context.ts'; +import { pathFor, type RouteId } from '../../router/routes.ts'; +import { useI18n } from '../../i18n/core.ts'; +import { Icon } from '../common/Icon.tsx'; + +const NAV: RouteId[] = ['home', 'backup', 'restore', 'library', 'history']; + +export function Logo() { + return ( + <span className="flex h-10 w-10 items-center justify-center rounded-lg bg-linear-to-br from-kobo-accent to-kobo-accent-dark"> + <Icon type="book" size={24} className="text-white" /> + </span> + ); +} + +export function Header() { + const { m, lang } = useI18n(); + const { route, navigate } = useRouter(); + const [open, setOpen] = useState(false); + const otherLang = lang === 'en' ? 'it' : 'en'; + const otherHref = route.id === 'notFound' ? pathFor('home', otherLang) : pathFor(route.id, otherLang); + + const linkClass = (id: RouteId) => + `rounded-lg px-3 py-2 font-medium transition-colors focus-visible-ring ${ + route.id === id + ? 'bg-kobo-accent text-kobo-dark' + : 'text-kobo-gray hover:bg-kobo-cream-dark hover:text-kobo-dark' + }`; + + return ( + <header className="sticky top-0 z-40 bg-white shadow-xs"> + <div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8"> + <Link + to="home" + className="flex items-center gap-3 rounded-lg focus-visible-ring" + aria-label={m.nav.logoLabel} + > + <Logo /> + <span className="hidden sm:block"> + <span className="block font-display text-xl font-bold text-kobo-dark">KoBup</span> + <span className="block text-xs text-kobo-gray">{m.meta.tagline}</span> + </span> + </Link> + + <nav aria-label={m.nav.label} className="hidden items-center gap-1 md:flex"> + {NAV.map((id) => ( + <Link key={id} to={id} className={linkClass(id)}> + {m.nav[id]} + </Link> + ))} + </nav> + + <div className="flex items-center gap-2"> + <a + href={otherHref} + hrefLang={otherLang} + lang={otherLang} + aria-label={m.nav.switchLanguageLabel} + onClick={(e) => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + navigate(otherHref); + }} + className="flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-kobo-gray hover:bg-kobo-cream-dark hover:text-kobo-dark focus-visible-ring" + > + <Icon type="globe" size="sm" /> + {m.nav.switchLanguage} + </a> + <button + type="button" + className="rounded-lg p-2 hover:bg-kobo-cream-dark focus-visible-ring md:hidden" + aria-expanded={open} + aria-controls="mobile-nav" + aria-label={open ? m.nav.closeMenu : m.nav.openMenu} + onClick={() => setOpen((v) => !v)} + > + <Icon type={open ? 'x' : 'menu'} size="lg" className="text-kobo-dark" /> + </button> + </div> + </div> + + {open && ( + <nav + id="mobile-nav" + aria-label={m.nav.label} + className="animate-slide-down border-t border-kobo-cream-dark px-4 py-3 md:hidden" + > + {NAV.map((id) => ( + <Link key={id} to={id} className={`block ${linkClass(id)}`} onClick={() => setOpen(false)}> + {m.nav[id]} + </Link> + ))} + </nav> + )} + </header> + ); +} diff --git a/src/components/library/CoverImage.tsx b/src/components/library/CoverImage.tsx new file mode 100644 index 0000000..ac90ab2 --- /dev/null +++ b/src/components/library/CoverImage.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from 'react'; +import { getCoverUrl } from '../../utils/koboCovers.ts'; +import type { KoboSource } from '../../utils/deviceSource.ts'; + +/** Deterministic colors per title for the generated placeholder cover. */ +function bookColors(title: string) { + let hash = 0; + for (let i = 0; i < title.length; i++) hash = (title.charCodeAt(i) + ((hash << 5) - hash)) | 0; + const hue = Math.abs(hash % 360); + const saturation = 45 + Math.abs((hash >> 8) % 25); + const lightness = 25 + Math.abs((hash >> 16) % 20); + return { + bg: `hsl(${hue} ${saturation}% ${lightness}%)`, + bgLight: `hsl(${hue} ${saturation}% ${lightness + 15}%)`, + accent: `hsl(${(hue + 40) % 360} ${saturation + 10}% 80%)`, + }; +} + +interface CoverImageProps { + source: KoboSource | null; + coverId: string | null; + title: string; + author: string; + className?: string; +} + +/** + * Real Kobo thumbnail when available, loaded only once the cover scrolls into + * view (hundreds of books otherwise mean thousands of file lookups). + */ +export function CoverImage({ source, coverId, title, author, className = '' }: CoverImageProps) { + const ref = useRef<HTMLDivElement>(null); + const [visible, setVisible] = useState(() => typeof IntersectionObserver === 'undefined'); + const [url, setUrl] = useState<string | null>(null); + + useEffect(() => { + const el = ref.current; + if (!el || visible) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setVisible(true); + observer.disconnect(); + } + }, + { rootMargin: '300px' }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [visible]); + + useEffect(() => { + if (!visible || !source || !coverId) return; + let active = true; + let created: string | null = null; + getCoverUrl(source, coverId) + .then((u) => { + created = u; + if (active) setUrl(u); + else if (u) URL.revokeObjectURL(u); + }) + .catch(() => {}); + return () => { + active = false; + if (created) URL.revokeObjectURL(created); + }; + }, [visible, source, coverId]); + + const colors = bookColors(title); + const frame = `relative aspect-3/4 overflow-hidden rounded-md shadow-md ${className}`; + + return ( + <div + ref={ref} + className={frame} + style={url ? undefined : { background: `linear-gradient(135deg, ${colors.bgLight}, ${colors.bg})` }} + > + <div className="absolute left-0 top-0 z-10 h-full w-1 bg-black/25" aria-hidden="true" /> + {url ? ( + <img src={url} alt="" className="h-full w-full object-cover" decoding="async" /> + ) : ( + <div + className="absolute inset-0 flex flex-col justify-center p-3 text-left text-white" + aria-hidden="true" + > + <span className="mb-2 line-clamp-3 font-display text-sm font-bold leading-tight">{title}</span> + <span className="line-clamp-2 text-xs uppercase tracking-wider" style={{ color: colors.accent }}> + {author} + </span> + </div> + )} + </div> + ); +} diff --git a/src/components/restore/BackupPreview.jsx b/src/components/restore/BackupPreview.jsx deleted file mode 100644 index 01fdc12..0000000 --- a/src/components/restore/BackupPreview.jsx +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Preview backup contents before restoring - */ - -import { Card } from '../common/Card.tsx'; -import { Button } from '../common/Button.tsx'; -import { Icon } from '../common/Icon.tsx'; -import { StatusBadge } from '../common/StatusBadge.tsx'; -import { formatDate } from '../../utils/formatters.ts'; - -export function BackupPreview({ preview, compatibility, onRestore, onCancel }) { - return ( - <div className="max-w-3xl mx-auto"> - <Card> - <h2 className="text-3xl font-display text-kobo-dark mb-2 text-center">Backup Details</h2> - <p className="text-lg font-body text-kobo-gray mb-8 text-center"> - Review your backup before restoring - </p> - - {/* Backup Info */} - <div className="grid grid-cols-2 gap-4 mb-8"> - <InfoItem label="Created" value={formatDate(preview.created)} /> - <InfoItem label="Device Model" value={preview.deviceModel} /> - <InfoItem label="Books" value={preview.statistics.totalBooks} /> - <InfoItem label="Annotations" value={preview.statistics.totalAnnotations} /> - </div> - - {/* Reading Stats */} - <div className="p-4 bg-kobo-cream-dark rounded-lg mb-8"> - <h3 className="font-semibold font-body text-kobo-dark mb-3">Reading Statistics</h3> - <div className="grid grid-cols-3 gap-4 text-center"> - <div> - <p className="text-2xl font-display text-kobo-dark">{preview.statistics.booksStarted}</p> - <p className="text-sm font-body text-kobo-gray">Started</p> - </div> - <div> - <p className="text-2xl font-display text-kobo-dark">{preview.statistics.booksFinished}</p> - <p className="text-sm font-body text-kobo-gray">Finished</p> - </div> - <div> - <p className="text-2xl font-display text-kobo-dark"> - {Math.floor(preview.statistics.totalReadingTime / 60)}h - </p> - <p className="text-sm font-body text-kobo-gray">Reading Time</p> - </div> - </div> - </div> - - {/* Compatibility Check */} - {compatibility && ( - <div className="mb-8"> - <h3 className="font-semibold font-body text-kobo-dark mb-3">Compatibility</h3> - - {compatibility.warnings.length === 0 ? ( - <StatusBadge status="success"> - <Icon type="check" size={16} /> - Fully compatible - </StatusBadge> - ) : ( - <div className="space-y-2"> - {compatibility.warnings.map((warning, index) => ( - <div - key={index} - className="p-3 bg-kobo-warning/10 border border-kobo-warning/20 rounded-lg text-sm font-body text-kobo-gray" - > - <div className="flex items-start gap-2"> - <Icon type="alert" size={16} className="text-kobo-warning shrink-0 mt-0.5" /> - <span>{warning.message}</span> - </div> - </div> - ))} - </div> - )} - </div> - )} - - {/* Contents */} - <div className="p-4 bg-kobo-info/10 border border-kobo-info/20 rounded-lg mb-8"> - <h3 className="font-semibold font-body text-kobo-dark mb-3">Backup Contents</h3> - <div className="space-y-2 text-sm font-body"> - <div className="flex items-center justify-between"> - <span className="text-kobo-gray">Database</span> - <Icon type="check" size={16} className="text-kobo-success" /> - </div> - <div className="flex items-center justify-between"> - <span className="text-kobo-gray">Books ({preview.contents.bookCount})</span> - {preview.contents.booksIncluded ? ( - <Icon type="check" size={16} className="text-kobo-success" /> - ) : ( - <Icon type="x" size={16} className="text-kobo-gray-light" /> - )} - </div> - <div className="flex items-center justify-between"> - <span className="text-kobo-gray">Annotations</span> - {preview.contents.annotationsIncluded ? ( - <Icon type="check" size={16} className="text-kobo-success" /> - ) : ( - <Icon type="x" size={16} className="text-kobo-gray-light" /> - )} - </div> - </div> - </div> - - {/* Action Buttons */} - <div className="flex flex-col sm:flex-row gap-4 justify-center"> - <Button size="lg" variant="secondary" onClick={onCancel}> - Cancel - </Button> - <Button size="lg" variant="primary" onClick={onRestore}> - <Icon type="restore" size={20} /> - Restore This Backup - </Button> - </div> - </Card> - </div> - ); -} - -function InfoItem({ label, value }) { - return ( - <div className="p-3 bg-white rounded-lg border-2 border-kobo-cream-dark"> - <p className="text-sm font-body text-kobo-gray mb-1">{label}</p> - <p className="font-semibold font-body text-kobo-dark">{value}</p> - </div> - ); -} diff --git a/src/components/restore/FileUploader.jsx b/src/components/restore/FileUploader.jsx deleted file mode 100644 index 1d8d885..0000000 --- a/src/components/restore/FileUploader.jsx +++ /dev/null @@ -1,147 +0,0 @@ -/** - * File uploader for backup ZIP files (drag and drop) - */ - -import { useState, useCallback } from 'react'; -import { Card } from '../common/Card.tsx'; -import { Icon } from '../common/Icon.tsx'; - -const LARGE_FILE_THRESHOLD = 2 * 1024 * 1024 * 1024; // 2 GB - -export function FileUploader({ onFileSelect, error }) { - const [isDragging, setIsDragging] = useState(false); - const [largeFileWarning, setLargeFileWarning] = useState(false); - - const handleDragOver = useCallback((e) => { - e.preventDefault(); - setIsDragging(true); - }, []); - - const handleDragLeave = useCallback((e) => { - e.preventDefault(); - setIsDragging(false); - }, []); - - const handleDrop = useCallback( - (e) => { - e.preventDefault(); - setIsDragging(false); - - const files = Array.from(e.dataTransfer.files); - const zipFile = files.find((f) => f.name.endsWith('.zip')); - - if (zipFile) { - setLargeFileWarning(zipFile.size > LARGE_FILE_THRESHOLD); - onFileSelect(zipFile); - } - }, - [onFileSelect], - ); - - const handleFileInput = useCallback( - (e) => { - const file = e.target.files?.[0]; - if (file) { - setLargeFileWarning(file.size > LARGE_FILE_THRESHOLD); - onFileSelect(file); - } - }, - [onFileSelect], - ); - - return ( - <div className="max-w-2xl mx-auto"> - <Card> - <h2 className="text-3xl font-display text-kobo-dark mb-2 text-center">Select Backup File</h2> - <p className="text-lg font-body text-kobo-gray mb-8 text-center"> - Choose your Kobo backup ZIP file to restore - </p> - - {/* Browser Compatibility Warning */} - <div className="mb-6 p-4 bg-kobo-info/10 border border-kobo-info/20 rounded-lg"> - <div className="flex items-start gap-3"> - <Icon type="info" className="text-kobo-info shrink-0 mt-1" /> - <div className="text-left"> - <p className="text-sm font-semibold font-body text-kobo-dark mb-1">Browser Compatibility</p> - <p className="text-sm font-body text-kobo-gray"> - Restore requires Chrome or Edge desktop browser. Not supported in Firefox, Safari, or mobile - browsers. - </p> - </div> - </div> - </div> - - {/* Drop Zone */} - <div - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - className={` - border-4 border-dashed rounded-2xl p-12 text-center transition-all - ${ - isDragging - ? 'border-kobo-accent bg-kobo-accent/5' - : 'border-kobo-gray-light bg-kobo-cream-dark/30' - } - `} - > - <Icon - type="upload" - size={64} - className={`mx-auto mb-4 ${isDragging ? 'text-kobo-accent' : 'text-kobo-gray-light'}`} - /> - - <h3 className="text-xl font-body text-kobo-dark mb-2"> - {isDragging ? 'Drop your backup file here' : 'Drag & drop your backup file'} - </h3> - - <p className="font-body text-kobo-gray mb-6">or</p> - - <input type="file" accept=".zip" onChange={handleFileInput} className="hidden" id="file-input" /> - - <label - htmlFor="file-input" - className="inline-flex items-center justify-center gap-2 px-6 py-3 text-lg font-semibold rounded-lg bg-kobo-cream-dark hover:bg-kobo-gray-light/10 text-kobo-dark border-2 border-kobo-gray-light transition-colors cursor-pointer" - > - <Icon type="folder" size={20} /> - Browse Files - </label> - - <p className="text-sm font-body text-kobo-gray mt-4">Looking for: kobo_backup_*.zip</p> - </div> - - {/* Large file warning */} - {largeFileWarning && !error && ( - <div className="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg"> - <div className="flex items-start gap-3"> - <Icon type="alert" className="text-yellow-600 shrink-0" /> - <div className="text-left"> - <h4 className="font-semibold font-body text-yellow-800 mb-1"> - Large backup detected (>2 GB) - </h4> - <p className="text-sm font-body text-yellow-700"> - Restoring large backups requires loading the entire ZIP into browser memory. Make sure you - have enough free RAM and no other heavy tabs open. If it fails, close other applications and - try again. - </p> - </div> - </div> - </div> - )} - - {/* Error Message */} - {error && ( - <div className="mt-6 p-4 bg-kobo-error/10 border-2 border-kobo-error/20 rounded-lg"> - <div className="flex items-start gap-3"> - <Icon type="alert" className="text-kobo-error shrink-0" /> - <div className="text-left"> - <h4 className="font-semibold font-body text-kobo-error mb-1">{error.title}</h4> - <p className="text-sm font-body text-kobo-gray">{error.message}</p> - </div> - </div> - </div> - )} - </Card> - </div> - ); -} diff --git a/src/components/restore/RestoreDeviceSelector.jsx b/src/components/restore/RestoreDeviceSelector.jsx deleted file mode 100644 index bff2e62..0000000 --- a/src/components/restore/RestoreDeviceSelector.jsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Device selector for restore flow - distinct from backup flow - */ - -import { Card } from '../common/Card.tsx'; -import { Button } from '../common/Button.tsx'; -import { Icon } from '../common/Icon.tsx'; - -export function RestoreDeviceSelector({ onSelectDevice, isSelecting, error }) { - return ( - <div className="max-w-4xl mx-auto"> - <Card> - {/* Header */} - <div className="text-center mb-8"> - <div className="w-20 h-20 bg-kobo-info/10 rounded-full flex items-center justify-center mx-auto mb-4"> - <Icon type="restore" size="lg" className="text-kobo-info" /> - </div> - <h2 className="text-3xl font-display text-kobo-dark mb-2">Select Restore Destination</h2> - <p className="text-lg font-body text-kobo-gray"> - Choose the Kobo device where you want to restore your backup - </p> - </div> - - {/* Important Warning */} - <div className="mb-8 p-4 bg-kobo-warning/10 border-2 border-kobo-warning/30 rounded-lg"> - <div className="flex items-start gap-3"> - <Icon type="alert" className="text-kobo-warning shrink-0 mt-0.5" /> - <div> - <h4 className="font-display font-semibold text-kobo-dark mb-1"> - Important: Data Will Be Replaced - </h4> - <p className="text-sm font-body text-kobo-gray"> - Restoring will replace the current database on your Kobo device. Make sure you've backed up - your current library if needed. - </p> - </div> - </div> - </div> - - {/* Prerequisites Checklist */} - <div className="mb-8 p-6 bg-kobo-cream-dark rounded-lg"> - <h3 className="font-display font-semibold text-kobo-dark mb-4">Before You Continue</h3> - <div className="space-y-3"> - <ChecklistItem text="Your Kobo device is connected via USB" /> - <ChecklistItem text="You've tapped 'Connect' on your Kobo screen" /> - <ChecklistItem text="Your Kobo appears as a drive (KOBOeReader)" /> - <ChecklistItem text="You have a backup of current data (if needed)" warning /> - </div> - </div> - - {/* Error Message */} - {error && ( - <div className="mb-6 p-4 bg-kobo-error/10 border-2 border-kobo-error/20 rounded-lg"> - <div className="flex items-start gap-3"> - <Icon type="alert" className="text-kobo-error shrink-0" /> - <div className="text-left"> - <h4 className="font-display font-semibold text-kobo-error mb-1">{error.title}</h4> - <p className="text-sm font-body text-kobo-gray">{error.message}</p> - </div> - </div> - </div> - )} - - {/* Action Buttons */} - <div className="flex flex-col sm:flex-row gap-4 justify-center"> - <Button size="lg" variant="primary" onClick={onSelectDevice} loading={isSelecting}> - <Icon type="folder" size={20} /> - {isSelecting ? 'Waiting for selection...' : 'Choose Kobo Device'} - </Button> - </div> - - {/* Help Text */} - <div className="mt-6 pt-6 border-t border-kobo-cream-dark text-center"> - <p className="text-sm font-body text-kobo-gray"> - Select the root folder of your Kobo device (usually named "KOBOeReader") - </p> - </div> - </Card> - </div> - ); -} - -function ChecklistItem({ text, warning = false }) { - return ( - <div className="flex items-start gap-2"> - <Icon - type={warning ? 'alert' : 'check'} - size={16} - className={`shrink-0 mt-0.5 ${warning ? 'text-kobo-warning' : 'text-kobo-success'}`} - /> - <span className="text-sm font-body text-kobo-dark">{text}</span> - </div> - ); -} diff --git a/src/components/restore/RestoreOptions.jsx b/src/components/restore/RestoreOptions.jsx deleted file mode 100644 index 2a9dcea..0000000 --- a/src/components/restore/RestoreOptions.jsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Restore options - choose what to restore and confirm - */ - -import { Card } from '../common/Card.tsx'; -import { Button } from '../common/Button.tsx'; -import { Checkbox } from '../common/Checkbox.tsx'; -import { Icon } from '../common/Icon.tsx'; - -export function RestoreOptions({ - options, - onOptionsChange, - onConfirm, - onCancel, - bookCount, - annotationCount, -}) { - const handleConfirmChange = (checked) => { - onOptionsChange({ ...options, confirmed: checked }); - }; - - return ( - <div className="max-w-2xl mx-auto"> - <Card> - <h2 className="text-3xl font-display text-kobo-dark mb-2 text-center">Restore Options</h2> - <p className="text-lg font-body text-kobo-gray mb-8 text-center"> - Choose what to restore to your Kobo - </p> - - {/* Options */} - <div className="space-y-4 mb-8"> - <Checkbox - checked={true} - disabled={true} - label={`All Books (${bookCount} files)`} - sublabel="Required" - /> - - <Checkbox - checked={options.includeAnnotations} - onChange={(checked) => onOptionsChange({ ...options, includeAnnotations: checked })} - label={`Annotations & Highlights (${annotationCount})`} - sublabel="Restore your notes and highlights" - /> - - <Checkbox - checked={options.includeProgress} - onChange={(checked) => onOptionsChange({ ...options, includeProgress: checked })} - label="Reading Progress" - sublabel="Continue where you left off" - /> - - <Checkbox - checked={options.cleanExistingBooks || false} - onChange={(checked) => onOptionsChange({ ...options, cleanExistingBooks: checked })} - label="Remove existing book folders first" - sublabel="Deletes the device's current book folders before restoring. Leave off to keep any books you added after this backup." - /> - </div> - - {/* Warning */} - <div className="p-4 bg-kobo-error/10 border-2 border-kobo-error/30 rounded-lg mb-8"> - <div className="flex items-start gap-3"> - <Icon type="alert" className="text-kobo-error shrink-0 mt-1" /> - <div> - <h4 className="font-semibold font-body text-kobo-error mb-2"> - Warning: This Will Erase Current Device Content - </h4> - <p className="text-sm font-body text-kobo-gray mb-3"> - Restoring this backup will replace all current content on your Kobo device. Make sure you have - a backup of any important data before proceeding. - </p> - <ul className="text-sm font-body text-kobo-gray space-y-1 list-disc list-inside"> - <li>Current sideloaded books will be replaced</li> - <li>Reading progress will be overwritten</li> - <li>Annotations will be replaced</li> - </ul> - </div> - </div> - </div> - - {/* Confirmation */} - <div className="mb-8"> - <Checkbox - checked={options.confirmed || false} - onChange={handleConfirmChange} - label={ - <span className="font-semibold">I understand this will replace all content on the device</span> - } - /> - </div> - - {/* Action Buttons */} - <div className="flex flex-col sm:flex-row gap-4 justify-center"> - <Button size="lg" variant="secondary" onClick={onCancel}> - Cancel - </Button> - <Button size="lg" variant="danger" onClick={onConfirm} disabled={!options.confirmed}> - <Icon type="restore" size={20} /> - Start Restore - </Button> - </div> - </Card> - </div> - ); -} diff --git a/src/components/restore/RestoreProgress.jsx b/src/components/restore/RestoreProgress.jsx deleted file mode 100644 index cb619f6..0000000 --- a/src/components/restore/RestoreProgress.jsx +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Restore progress screen - */ - -import { Card } from '../common/Card.tsx'; -import { CircularProgress } from '../common/CircularProgress.tsx'; -import { Icon } from '../common/Icon.tsx'; - -export function RestoreProgress({ progress }) { - const { stage, percent, filesProcessed, totalFiles } = progress; - - return ( - <div className="max-w-2xl mx-auto"> - <Card className="text-center"> - <h2 className="text-3xl font-display text-kobo-dark mb-8">Restoring Your Library</h2> - - {/* Circular Progress */} - <div className="mb-8 flex justify-center"> - <CircularProgress percent={percent} size={200} /> - </div> - - {/* Current Stage */} - <p className="text-xl font-body text-kobo-dark mb-8">{stage || 'Preparing...'}</p> - - {/* Files Progress */} - {filesProcessed !== undefined && totalFiles > 0 && ( - <div className="mb-8"> - <p className="font-body text-kobo-gray mb-2">Files Copied</p> - <p className="text-2xl font-display text-kobo-dark"> - {filesProcessed} / {totalFiles} - </p> - </div> - )} - - {/* Stages */} - <div className="space-y-3 mb-8"> - <StageItem - text="Preparing device" - status={percent >= 10 ? 'completed' : percent > 0 ? 'active' : 'pending'} - /> - <StageItem - text="Copying database" - status={percent >= 20 ? 'completed' : percent > 10 ? 'active' : 'pending'} - /> - <StageItem - text="Transferring books" - status={percent >= 90 ? 'completed' : percent > 20 ? 'active' : 'pending'} - /> - <StageItem - text="Restoring annotations" - status={percent >= 95 ? 'completed' : percent > 90 ? 'active' : 'pending'} - /> - <StageItem - text="Finalizing" - status={percent >= 100 ? 'completed' : percent > 95 ? 'active' : 'pending'} - /> - </div> - - {/* Critical Warning */} - <div className="p-4 bg-kobo-error/10 border-2 border-kobo-error/30 rounded-lg"> - <div className="flex items-center gap-3"> - <Icon type="alert" className="text-kobo-error shrink-0" /> - <p className="text-sm font-semibold font-body text-kobo-error text-left"> - DO NOT disconnect your Kobo until complete! - </p> - </div> - </div> - </Card> - </div> - ); -} - -function StageItem({ text, status }) { - return ( - <div className={`flex items-center gap-3 ${status === 'pending' ? 'opacity-50' : ''}`}> - {status === 'completed' && <Icon type="check" size={20} className="text-kobo-success" />} - {status === 'active' && ( - <div className="w-5 h-5 border-2 border-kobo-accent border-t-transparent rounded-full animate-spin" /> - )} - {status === 'pending' && ( - <div className="w-5 h-5 border-2 border-kobo-gray-light rounded-full opacity-30" /> - )} - <span className="font-body text-kobo-dark">{text}</span> - </div> - ); -} diff --git a/src/components/restore/RestoreSuccess.jsx b/src/components/restore/RestoreSuccess.jsx deleted file mode 100644 index c8cceaf..0000000 --- a/src/components/restore/RestoreSuccess.jsx +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Restore success screen - */ - -import { Card } from '../common/Card.tsx'; -import { Button } from '../common/Button.tsx'; -import { Icon } from '../common/Icon.tsx'; - -export function RestoreSuccess({ result, onDone }) { - const failedBooks = result.failedBooks || []; - const verification = result.verification || null; - const hasFailures = failedBooks.length > 0; - const verificationFailed = verification && !verification.ok; - - return ( - <div className="max-w-3xl mx-auto"> - <Card className="text-center"> - {/* Success / Partial-success Animation */} - <div - className={`w-24 h-24 ${hasFailures ? 'bg-yellow-100' : 'bg-kobo-success/10'} rounded-full flex items-center justify-center mx-auto mb-6 animate-bounce`} - > - <Icon - type={hasFailures ? 'warning' : 'check'} - size={48} - className={hasFailures ? 'text-yellow-500' : 'text-kobo-success'} - /> - </div> - - <h2 className="text-4xl font-display text-kobo-dark mb-4"> - {hasFailures ? 'Restore Completed with Warnings' : 'Restore Complete!'} - </h2> - - <p className="text-lg font-body text-kobo-gray mb-8"> - {hasFailures - ? `${result.booksRestored || 0} books restored, ${failedBooks.length} failed` - : 'Your library has been successfully restored to your Kobo'} - </p> - - {/* Summary */} - <div className="grid grid-cols-3 gap-4 mb-8"> - <SummaryCard icon="book" value={result.booksRestored || 0} label="Books Restored" /> - <SummaryCard - icon="note" - value={result.metadata?.statistics?.totalAnnotations || 0} - label="Annotations" - /> - <SummaryCard icon="bookmark" value="All" label="Progress" /> - </div> - - {/* Failed Books Warning */} - {hasFailures && ( - <div className="text-left p-4 bg-yellow-50 border border-yellow-200 rounded-lg mb-6"> - <h3 className="font-bold font-body text-yellow-800 mb-2 flex items-center gap-2"> - <Icon type="warning" size={18} /> - {failedBooks.length} book{failedBooks.length > 1 ? 's' : ''} could not be restored - </h3> - <ul className="space-y-1 max-h-40 overflow-y-auto"> - {failedBooks.map((b, i) => ( - <li key={i} className="text-sm font-body text-yellow-700 flex items-start gap-2"> - <span className="mt-0.5 shrink-0">•</span> - <span> - <span className="font-medium">{b.name}</span> - {b.error && <span className="text-yellow-600"> — {b.error}</span>} - </span> - </li> - ))} - </ul> - </div> - )} - - {/* Verification Warning */} - {verificationFailed && ( - <div className="text-left p-4 bg-orange-50 border border-orange-200 rounded-lg mb-6"> - <h3 className="font-bold font-body text-orange-800 mb-1 flex items-center gap-2"> - <Icon type="warning" size={18} /> - Database count mismatch - </h3> - <p className="text-sm font-body text-orange-700"> - The restored database contains {verification.dbBooksCount} books but the backup reported{' '} - {verification.expectedCount}. Some records may not have transferred. Try ejecting the device and - reconnecting to trigger a full rescan. - </p> - </div> - )} - - {/* Next Steps */} - <div className="text-left p-6 bg-kobo-cream-dark rounded-lg mb-8"> - <h3 className="font-bold font-body text-kobo-dark mb-4 flex items-center gap-2"> - <Icon type="info" size={20} /> - What's Next? - </h3> - <div className="space-y-4"> - <div className="flex items-start gap-3"> - <div className="w-8 h-8 bg-kobo-accent/10 rounded-full flex items-center justify-center shrink-0"> - <span className="text-kobo-accent font-body font-bold">1</span> - </div> - <div> - <p className="font-medium font-body text-kobo-dark">Safely eject your Kobo</p> - <p className="text-sm font-body text-kobo-gray"> - Use your operating system's "Eject" or "Safely Remove Hardware" option - </p> - </div> - </div> - - <div className="flex items-start gap-3"> - <div className="w-8 h-8 bg-kobo-accent/10 rounded-full flex items-center justify-center shrink-0"> - <span className="text-kobo-accent font-body font-bold">2</span> - </div> - <div> - <p className="font-medium font-body text-kobo-dark">Disconnect USB cable</p> - <p className="text-sm font-body text-kobo-gray">Unplug the USB cable from your Kobo device</p> - </div> - </div> - - <div className="flex items-start gap-3"> - <div className="w-8 h-8 bg-kobo-accent/10 rounded-full flex items-center justify-center shrink-0"> - <span className="text-kobo-accent font-body font-bold">3</span> - </div> - <div> - <p className="font-medium font-body text-kobo-dark">Your books are ready!</p> - <p className="text-sm font-body text-kobo-gray"> - Open your Kobo and start reading from where you left off - </p> - </div> - </div> - </div> - </div> - - {/* Success / Partial message */} - <div - className={`p-4 ${hasFailures ? 'bg-yellow-50 border border-yellow-200' : 'bg-kobo-success/10 border border-kobo-success/20'} rounded-lg mb-8`} - > - <p className="font-body text-kobo-dark"> - {hasFailures ? ( - <> - <Icon type="warning" size={16} className="inline text-yellow-500 mr-2" /> - Books and reading progress have been restored. Check the warnings above for any issues. - </> - ) : ( - <> - <Icon type="check" size={16} className="inline text-kobo-success mr-2" /> - All your books, annotations, and reading progress have been restored - </> - )} - </p> - </div> - - {/* Done Button */} - <Button size="lg" variant="primary" onClick={onDone} className="w-full sm:w-auto"> - Done - </Button> - </Card> - </div> - ); -} - -function SummaryCard({ icon, value, label }) { - return ( - <div className="p-4 bg-white rounded-lg border-2 border-kobo-cream-dark"> - <Icon type={icon} size={24} className="text-kobo-accent mx-auto mb-2" /> - <p className="text-2xl font-display text-kobo-dark mb-1">{value}</p> - <p className="text-sm font-body text-kobo-gray">{label}</p> - </div> - ); -} diff --git a/src/components/restore/RestoreWizard.jsx b/src/components/restore/RestoreWizard.jsx deleted file mode 100644 index 616a82c..0000000 --- a/src/components/restore/RestoreWizard.jsx +++ /dev/null @@ -1,146 +0,0 @@ -/** - * Restore wizard - orchestrates the entire restore flow - */ - -import { useState } from 'react'; -import { useFileSystem } from '../../hooks/useFileSystem.ts'; -import { useRestore } from '../../hooks/useRestore.ts'; -import { Container } from '../layout/Container.tsx'; - -import { RestoreDeviceSelector } from './RestoreDeviceSelector.jsx'; -import { FileUploader } from './FileUploader.jsx'; -import { BackupPreview } from './BackupPreview.jsx'; -import { RestoreOptions } from './RestoreOptions.jsx'; -import { RestoreProgress } from './RestoreProgress.jsx'; -import { RestoreSuccess } from './RestoreSuccess.jsx'; - -export function RestoreWizard({ onComplete }) { - const [step, setStep] = useState('upload'); // upload, selectDevice, preview, options, progress, success - const [restoreOptions, setRestoreOptions] = useState({ - includeAnnotations: true, - includeProgress: true, - cleanExistingBooks: false, - confirmed: false, - }); - const [deviceHandle, setDeviceHandle] = useState(null); - - const fileSystem = useFileSystem(); - const restore = useRestore(); - - // Step 1: Upload backup file - const handleFileSelect = async (file) => { - try { - await restore.parse(file); - setStep('selectDevice'); - } catch (error) { - console.error('Failed to parse backup:', error); - } - }; - - // Step 2: Select target device - const handleSelectDevice = async () => { - const dirHandle = await fileSystem.selectDirectory(); - - if (dirHandle) { - setDeviceHandle(dirHandle); - - // Check compatibility - if (restore.backupData) { - // For now, just proceed to preview - // In a real app, you'd extract device info and check compatibility - setStep('preview'); - } - } - }; - - // Step 3: After preview, go to options - const handleConfirmPreview = () => { - setStep('options'); - }; - - // Step 4: Start restore - const handleStartRestore = async () => { - if (!deviceHandle) { - console.error('No device selected'); - return; - } - - setStep('progress'); - - try { - const result = await restore.restore(deviceHandle, restoreOptions); - - if (result) { - setStep('success'); - } else { - // User cancelled or error - setStep('options'); - } - } catch (error) { - console.error('Restore failed:', error); - setStep('options'); - } - }; - - // Done or cancel - const handleDone = () => { - if (onComplete) { - onComplete(); - } - }; - - const handleCancel = () => { - // Go back to previous step or start - if (step === 'preview') { - setStep('selectDevice'); - } else if (step === 'options') { - setStep('preview'); - } else { - setStep('upload'); - restore.clear(); - setDeviceHandle(null); - } - }; - - return ( - <div className="min-h-screen py-12"> - <Container maxWidth="6xl"> - {step === 'upload' && <FileUploader onFileSelect={handleFileSelect} error={restore.error} />} - - {step === 'selectDevice' && ( - <RestoreDeviceSelector - onSelectDevice={handleSelectDevice} - isSelecting={fileSystem.isSelecting} - error={fileSystem.error} - /> - )} - - {step === 'preview' && restore.preview && ( - <BackupPreview - preview={restore.preview} - compatibility={restore.compatibility} - onRestore={handleConfirmPreview} - onCancel={handleCancel} - /> - )} - - {step === 'options' && restore.preview && ( - <RestoreOptions - options={restoreOptions} - onOptionsChange={setRestoreOptions} - onConfirm={handleStartRestore} - onCancel={handleCancel} - bookCount={restore.preview.contents.bookCount} - annotationCount={restore.preview.statistics.totalAnnotations} - /> - )} - - {step === 'progress' && <RestoreProgress progress={restore.progress} />} - - {step === 'success' && restore.result && ( - <RestoreSuccess result={restore.result} onDone={handleDone} /> - )} - </Container> - </div> - ); -} diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts index fe69541..25d4a0f 100644 --- a/src/hooks/useBackup.ts +++ b/src/hooks/useBackup.ts @@ -1,99 +1,82 @@ /** - * Backup orchestration (streaming-first). + * Backup orchestration (streaming-first). The ZIP code is loaded on demand. * - * - Chrome/Edge: BackupWizard calls showSaveFilePicker() first (user gesture) - * and passes the handle here -> streamBackupToDisk() -> no OOM. - * - Otherwise: createBackupBlob() + saveBackup() (may OOM on huge libraries). + * - Chrome/Edge: the wizard calls showSaveFilePicker() first (user gesture) + * and passes the handle here → streamed to disk, no memory limit. + * - Otherwise: the archive is built in memory and downloaded. */ - -import { useState, useCallback } from 'react'; -import { - streamBackupToDisk, - createBackupBlob, - saveBackup, - estimateBackupSize, - type BackupResult, -} from '../utils/backup.ts'; +import { useCallback, useState } from 'react'; +import type { BackupResult } from '../utils/backup.ts'; import { errorCode, errorMessage } from '../utils/errors.ts'; -import type { BackupOptions, ProgressState, ScanResult, UiError } from '../types/kobo.ts'; +import type { BackupOptions, BackupStage, ScanResult, UiError } from '../types/kobo.ts'; export interface CreateBackupOptions extends Partial<BackupOptions> { - /** From showSaveFilePicker (streaming path). */ writableFileHandle?: FileSystemFileHandle | null; - suggestedFilename?: string; - /** AES-256 encrypt the archive with this password. */ + suggestedFilename: string; password?: string; } -export type BackupOutcome = BackupResult & { duration: number }; +export type BackupOutcome = BackupResult & { duration: number; streamed: boolean }; + +export interface BackupProgressState { + stage: BackupStage; + percent: number; + filesProcessed: number; + totalFiles: number; +} -const IDLE: ProgressState = { stage: '', percent: 0, filesProcessed: 0, totalFiles: 0 }; +const IDLE: BackupProgressState = { stage: 'preparing', percent: 0, filesProcessed: 0, totalFiles: 0 }; export function useBackup() { - const [isCreating, setIsCreating] = useState(false); - const [progress, setProgress] = useState<ProgressState>(IDLE); + const [progress, setProgress] = useState<BackupProgressState>(IDLE); const [result, setResult] = useState<BackupOutcome | null>(null); const [error, setError] = useState<UiError | null>(null); + const [running, setRunning] = useState(false); - const create = useCallback(async (koboData: ScanResult, options: CreateBackupOptions = {}) => { - setIsCreating(true); + const create = useCallback(async (scan: ScanResult, options: CreateBackupOptions) => { + setRunning(true); setError(null); setResult(null); - const startTime = Date.now(); - const totalFiles = koboData.bookFiles?.length || 0; - const { writableFileHandle, suggestedFilename, ...backupOptions } = options; - - const onProgress = (stage: string, percent: number, filesProcessed?: number) => - setProgress({ + const { writableFileHandle, suggestedFilename, ...runOptions } = options; + const totalFiles = runOptions.includeBooks === false ? 0 : scan.bookFiles.length; + const onProgress = (stage: BackupStage, percent: number, filesProcessed?: number) => + setProgress((prev) => ({ stage, percent: Math.min(Math.round(percent || 0), 100), - filesProcessed: filesProcessed ?? 0, + filesProcessed: filesProcessed ?? prev.filesProcessed, totalFiles, - }); + })); try { - let backupResult: BackupResult; + const backup = await import('../utils/backup.ts'); + let outcome: BackupResult; if (writableFileHandle) { - backupResult = await streamBackupToDisk( - koboData, - writableFileHandle, - suggestedFilename ?? 'backup.zip', - { - ...backupOptions, - onProgress, - }, - ); + outcome = await backup.streamBackupToDisk(scan, writableFileHandle, suggestedFilename, { + ...runOptions, + onProgress, + }); } else { - const { blob, ...blobResult } = await createBackupBlob(koboData, { ...backupOptions, onProgress }); - saveBackup(blob, blobResult.filename); - backupResult = blobResult; + const { blob, ...rest } = await backup.createBackupBlob(scan, { ...runOptions, onProgress }); + backup.saveBackup(blob, suggestedFilename); + outcome = { ...rest, filename: suggestedFilename }; } - - const finalResult = { ...backupResult, duration: Date.now() - startTime }; - setResult(finalResult); - setProgress({ stage: 'Backup complete', percent: 100, filesProcessed: totalFiles, totalFiles }); - return finalResult; + const final = { ...outcome, duration: Date.now() - startTime, streamed: !!writableFileHandle }; + setResult(final); + return final; } catch (err) { - setError({ - title: 'Backup Failed', - message: errorMessage(err, 'Failed to create backup'), - code: errorCode(err, 'BACKUP_FAILED'), - }); - throw err; + setError({ title: 'Backup Failed', message: errorMessage(err), code: errorCode(err, 'BACKUP_FAILED') }); + return null; } finally { - setIsCreating(false); + setRunning(false); } }, []); - const estimateSize = useCallback((koboData: ScanResult) => estimateBackupSize(koboData), []); - const reset = useCallback(() => { - setIsCreating(false); setProgress(IDLE); setResult(null); setError(null); }, []); - return { isCreating, progress, result, error, create, estimateSize, reset, isComplete: result !== null }; + return { progress, result, error, running, create, reset }; } diff --git a/src/hooks/useDevice.ts b/src/hooks/useDevice.ts new file mode 100644 index 0000000..4b45347 --- /dev/null +++ b/src/hooks/useDevice.ts @@ -0,0 +1,87 @@ +/** + * Connect a Kobo (File System Access picker, or <input webkitdirectory> files + * as a fallback), scan it and share the result through the global store. + */ +import { useCallback, useState } from 'react'; +import { useKoboStore } from '../stores/koboStore.ts'; +import { selectKoboDirectory } from '../utils/fileSystem.ts'; +import { directorySource, fileListSource, type KoboSource } from '../utils/deviceSource.ts'; +import { scanKoboDevice, DATABASE_PATH } from '../utils/scan.ts'; +import { errorCode, errorMessage } from '../utils/errors.ts'; +import type { UiError } from '../types/kobo.ts'; + +export type ConnectStatus = 'idle' | 'selecting' | 'scanning' | 'ready' | 'error'; + +const invalidDevice: UiError = { + title: 'Not a Kobo Device', + message: 'Please select your Kobo drive (usually named "KOBOeReader")', + code: 'INVALID_DEVICE', +}; + +export function useDevice() { + const scan = useKoboStore((s) => s.scan); + const setDevice = useKoboStore((s) => s.setDevice); + const clearDevice = useKoboStore((s) => s.clearDevice); + const [status, setStatus] = useState<ConnectStatus>(scan ? 'ready' : 'idle'); + const [step, setStep] = useState(0); + const [error, setError] = useState<UiError | null>(null); + + const connect = useCallback( + async (source: KoboSource) => { + setError(null); + if (!(await source.getFile(DATABASE_PATH))) { + setError(invalidDevice); + setStatus('error'); + return null; + } + setStatus('scanning'); + setStep(0); + try { + const result = await scanKoboDevice(source, setStep); + setDevice(source, result); + setStatus('ready'); + return result; + } catch (err) { + setError({ title: 'Scan Failed', message: errorMessage(err), code: errorCode(err, 'SCAN_FAILED') }); + setStatus('error'); + return null; + } + }, + [setDevice], + ); + + const connectWithPicker = useCallback(async () => { + setStatus('selecting'); + setError(null); + try { + const handle = await selectKoboDirectory('read'); + if (!handle) { + setStatus(scan ? 'ready' : 'idle'); + return null; + } + return await connect(directorySource(handle)); + } catch (err) { + setError({ + title: 'Selection Failed', + message: errorMessage(err), + code: errorCode(err, 'FS_PERMISSION_DENIED'), + }); + setStatus('error'); + return null; + } + }, [connect, scan]); + + const connectWithFiles = useCallback( + (files: FileList | File[]) => + files.length ? connect(fileListSource(Array.from(files))) : Promise.resolve(null), + [connect], + ); + + const disconnect = useCallback(() => { + clearDevice(); + setError(null); + setStatus('idle'); + }, [clearDevice]); + + return { scan, status, step, error, connectWithPicker, connectWithFiles, disconnect }; +} diff --git a/src/hooks/useFeatureDetection.ts b/src/hooks/useFeatureDetection.ts index b7de46b..c6db703 100644 --- a/src/hooks/useFeatureDetection.ts +++ b/src/hooks/useFeatureDetection.ts @@ -8,9 +8,11 @@ export interface BrowserFeatures { hasModernFeatures: boolean; } +// Optimistic during prerender/hydration (most visitors use Chromium); the real +// values are applied right after hydration. const SERVER_FEATURES: BrowserFeatures = { - fileSystemAccess: false, - saveFilePicker: false, + fileSystemAccess: true, + saveFilePicker: true, webAssembly: true, isSupported: true, hasModernFeatures: true, diff --git a/src/hooks/useFileSystem.ts b/src/hooks/useFileSystem.ts deleted file mode 100644 index eb07dd2..0000000 --- a/src/hooks/useFileSystem.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useState, useCallback } from 'react'; -import { selectKoboDirectory } from '../utils/fileSystem.ts'; -import { isValidKoboDirectory } from '../utils/validation.ts'; -import { errorCode, errorMessage } from '../utils/errors.ts'; -import type { UiError } from '../types/kobo.ts'; - -/** Pick and validate the Kobo device folder. */ -export function useFileSystem() { - const [directory, setDirectory] = useState<FileSystemDirectoryHandle | null>(null); - const [isSelecting, setIsSelecting] = useState(false); - const [error, setError] = useState<UiError | null>(null); - - const selectDirectory = useCallback(async (): Promise<FileSystemDirectoryHandle | null> => { - setIsSelecting(true); - setError(null); - try { - const dirHandle = await selectKoboDirectory(); - if (!dirHandle) return null; - - if (!(await isValidKoboDirectory(dirHandle))) { - setError({ - title: 'Not a Kobo Device', - message: 'Please select your Kobo drive (usually named "KOBOeReader")', - code: 'INVALID_DEVICE', - }); - setDirectory(null); - return null; - } - - setDirectory(dirHandle); - return dirHandle; - } catch (err) { - setError({ - title: 'Selection Failed', - message: errorMessage(err, 'Failed to select directory'), - code: errorCode(err, 'UNKNOWN'), - }); - return null; - } finally { - setIsSelecting(false); - } - }, []); - - const clearDirectory = useCallback(() => { - setDirectory(null); - setError(null); - }, []); - - return { directory, isSelecting, error, selectDirectory, clearDirectory, hasDirectory: directory !== null }; -} diff --git a/src/hooks/useKoboDevice.ts b/src/hooks/useKoboDevice.ts deleted file mode 100644 index b615e63..0000000 --- a/src/hooks/useKoboDevice.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useState, useCallback } from 'react'; -import { scanKoboDevice, SCAN_STEPS } from '../utils/scan.ts'; -import { errorCode, errorMessage } from '../utils/errors.ts'; -import type { ScanResult, UiError } from '../types/kobo.ts'; -import type { KoboSource } from '../utils/deviceSource.ts'; - -export interface ScanProgress { - /** 1 = reading database, 2 = analyzing books, 3 = finding files, 4 = done. */ - current: number; - total: number; -} - -const IDLE: ScanProgress = { current: 0, total: SCAN_STEPS }; - -/** Scan a Kobo device: database + book files. */ -export function useKoboDevice() { - const [isScanning, setIsScanning] = useState(false); - const [scanProgress, setScanProgress] = useState<ScanProgress>(IDLE); - const [error, setError] = useState<UiError | null>(null); - - const scanDevice = useCallback(async (source: KoboSource): Promise<ScanResult> => { - setIsScanning(true); - setError(null); - try { - return await scanKoboDevice(source, (current) => setScanProgress({ current, total: SCAN_STEPS })); - } catch (err) { - setError({ - title: 'Scan Failed', - message: errorMessage(err, 'Failed to scan Kobo device'), - code: errorCode(err, 'SCAN_FAILED'), - }); - throw err; - } finally { - setIsScanning(false); - } - }, []); - - const clearDevice = useCallback(() => { - setError(null); - setScanProgress(IDLE); - }, []); - - return { isScanning, scanProgress, error, scanDevice, clearDevice }; -} diff --git a/src/hooks/useLocalFlag.ts b/src/hooks/useLocalFlag.ts new file mode 100644 index 0000000..adecd46 --- /dev/null +++ b/src/hooks/useLocalFlag.ts @@ -0,0 +1,35 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +const listeners = new Set<() => void>(); + +function read(key: string): boolean { + try { + return localStorage.getItem(key) !== null; + } catch { + return false; + } +} + +/** + * A boolean persisted in localStorage. On the server (prerender) it reads as + * `serverValue`, so the static HTML never contains client-only UI. + */ +export function useLocalFlag(key: string, serverValue = true): [boolean, () => void] { + const value = useSyncExternalStore( + (onChange) => { + listeners.add(onChange); + return () => listeners.delete(onChange); + }, + () => read(key), + () => serverValue, + ); + const set = useCallback(() => { + try { + localStorage.setItem(key, '1'); + } catch { + // storage unavailable (private mode): the flag only lasts for this page + } + listeners.forEach((l) => l()); + }, [key]); + return [value, set]; +} diff --git a/src/hooks/useRestore.ts b/src/hooks/useRestore.ts index a9de0e9..8cd4b5a 100644 --- a/src/hooks/useRestore.ts +++ b/src/hooks/useRestore.ts @@ -1,115 +1,136 @@ -import { useState, useCallback } from 'react'; -import { - parseBackupFile, - restoreToDevice, - checkCompatibility, - previewBackup, - validateRestoreTarget, - type BackupPreviewData, - type CompatibilityResult, - type ParsedBackup, - type RestoreResult, - type RestoreRunOptions, +/** + * Restore orchestration: parse → inspect target → restore → (undo). + * The ZIP/SQLite code is loaded on demand. + */ +import { useCallback, useState } from 'react'; +import type { + BackupPreviewData, + CompatibilityResult, + ParsedBackup, + RestoreResult, + RestoreRunOptions, + RestoreStage, + RestoreTargetInfo, } from '../utils/restore.ts'; import { errorCode, errorMessage } from '../utils/errors.ts'; -import type { DeviceInfo, ProgressState, UiError } from '../types/kobo.ts'; +import type { UiError } from '../types/kobo.ts'; -export type RestoreOutcome = RestoreResult & { duration: number }; +const loadRestore = () => import('../utils/restore.ts'); -const IDLE: ProgressState = { stage: '', percent: 0, filesProcessed: 0, totalFiles: 0 }; +export interface RestoreProgressState { + stage: RestoreStage; + percent: number; + filesProcessed: number; + totalFiles: number; +} + +const IDLE: RestoreProgressState = { stage: 'preparing', percent: 0, filesProcessed: 0, totalFiles: 0 }; + +const toUiError = (title: string, err: unknown, fallback: string): UiError => ({ + title, + message: errorMessage(err), + code: errorCode(err, fallback), +}); -/** Restore orchestration: parse → preview → restore. */ export function useRestore() { - const [isParsing, setIsParsing] = useState(false); - const [isRestoring, setIsRestoring] = useState(false); - const [backupData, setBackupData] = useState<ParsedBackup | null>(null); + const [backup, setBackup] = useState<ParsedBackup | null>(null); const [preview, setPreview] = useState<BackupPreviewData | null>(null); + const [target, setTarget] = useState<{ handle: FileSystemDirectoryHandle; info: RestoreTargetInfo } | null>( + null, + ); const [compatibility, setCompatibility] = useState<CompatibilityResult | null>(null); - const [progress, setProgress] = useState<ProgressState>(IDLE); - const [result, setResult] = useState<RestoreOutcome | null>(null); + const [progress, setProgress] = useState<RestoreProgressState>(IDLE); + const [result, setResult] = useState<RestoreResult | null>(null); const [error, setError] = useState<UiError | null>(null); + const [busy, setBusy] = useState(false); - const parse = useCallback(async (file: File, password?: string) => { - setIsParsing(true); + const parse = useCallback(async (file: Blob, password?: string) => { + setBusy(true); setError(null); try { + const { parseBackupFile, previewBackup } = await loadRestore(); const parsed = await parseBackupFile(file, password); - setBackupData(parsed); + setBackup(parsed); setPreview(previewBackup(parsed)); return parsed; } catch (err) { - setError({ - title: 'Invalid Backup File', - message: errorMessage(err, 'Failed to parse backup file'), - code: errorCode(err, 'PARSE_FAILED'), - }); - throw err; + setError(toUiError('Invalid Backup File', err, 'RESTORE_INVALID_FILE')); + return null; } finally { - setIsParsing(false); + setBusy(false); } }, []); - const checkDeviceCompatibility = useCallback( - (deviceInfo: Partial<DeviceInfo> | null) => { - if (!backupData) throw new Error('No backup loaded'); - const compat = checkCompatibility(backupData.metadata, deviceInfo); - setCompatibility(compat); - return compat; + const selectTarget = useCallback( + async (handle: FileSystemDirectoryHandle) => { + setBusy(true); + setError(null); + try { + const { inspectRestoreTarget, validateRestoreTarget, checkCompatibility } = await loadRestore(); + const validation = await validateRestoreTarget(handle); + if (!validation.valid) { + setError({ title: 'Invalid target', message: validation.error, code: 'INVALID_DEVICE' }); + return null; + } + const info = await inspectRestoreTarget(handle); + setTarget({ handle, info }); + if (backup) setCompatibility(checkCompatibility(backup.metadata, info.device)); + return info; + } catch (err) { + setError(toUiError('Invalid target', err, 'FS_PERMISSION_DENIED')); + return null; + } finally { + setBusy(false); + } }, - [backupData], + [backup], ); const restore = useCallback( - async (deviceHandle: FileSystemDirectoryHandle, options: RestoreRunOptions = {}) => { - if (!backupData) throw new Error('No backup loaded'); - - setIsRestoring(true); + async (options: Omit<RestoreRunOptions, 'onProgress'>) => { + if (!backup || !target) return null; + setBusy(true); setError(null); setResult(null); - const startTime = Date.now(); - const totalBooks = backupData.bookFiles.length; - try { - const validation = await validateRestoreTarget(deviceHandle); - if (!validation.valid) throw new Error(validation.error); - - const restoreResult = await restoreToDevice(deviceHandle, backupData, { + const { restoreToDevice } = await loadRestore(); + const res = await restoreToDevice(target.handle, backup, { ...options, onProgress: (p) => setProgress({ stage: p.stage, - percent: p.percent, - filesProcessed: p.filesProcessed || 0, - totalFiles: p.totalFiles || totalBooks, + percent: Math.round(p.percent), + filesProcessed: p.filesProcessed ?? 0, + totalFiles: p.totalFiles ?? 0, }), }); - - const finalResult = { ...restoreResult, duration: Date.now() - startTime }; - setResult(finalResult); - setProgress({ - stage: 'Restore complete', - percent: 100, - filesProcessed: totalBooks, - totalFiles: totalBooks, - }); - return finalResult; + setResult(res); + return res; } catch (err) { - setError({ - title: 'Restore Failed', - message: errorMessage(err, 'Failed to restore backup'), - code: errorCode(err, 'RESTORE_FAILED'), - }); - throw err; + setError(toUiError('Restore Failed', err, 'RESTORE_FAILED')); + return null; } finally { - setIsRestoring(false); + setBusy(false); } }, - [backupData], + [backup, target], ); - const clear = useCallback(() => { - setBackupData(null); + const undo = useCallback(async () => { + if (!target) return false; + try { + const { undoLastRestore } = await loadRestore(); + return await undoLastRestore(target.handle); + } catch (err) { + setError(toUiError('Undo failed', err, 'RESTORE_FAILED')); + return false; + } + }, [target]); + + const reset = useCallback(() => { + setBackup(null); setPreview(null); + setTarget(null); setCompatibility(null); setProgress(IDLE); setResult(null); @@ -117,19 +138,19 @@ export function useRestore() { }, []); return { - isParsing, - isRestoring, - backupData, + backup, preview, + target, compatibility, progress, result, error, + busy, parse, - checkDeviceCompatibility, + selectTarget, restore, - clear, - hasBackup: backupData !== null, - isComplete: result !== null, + undo, + reset, + clearError: () => setError(null), }; } diff --git a/src/i18n/core.ts b/src/i18n/core.ts new file mode 100644 index 0000000..74dc2fa --- /dev/null +++ b/src/i18n/core.ts @@ -0,0 +1,87 @@ +import { createContext, useContext } from 'react'; +import en, { type Messages } from './en.ts'; +import it from './it.ts'; +import type { Lang } from '../router/routes.ts'; + +export type { Messages }; + +export const MESSAGES: Record<Lang, Messages> = { en, it }; + +const LOCALES: Record<Lang, string> = { en: 'en-US', it: 'it-IT' }; + +export interface PluralForms { + one: string; + other: string; +} + +type Params = Record<string, string | number>; + +/** Replace {name} placeholders. */ +export function fmt(template: string, params: Params = {}): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => + key in params ? String(params[key]) : match, + ); +} + +export function createI18n(lang: Lang) { + const m = MESSAGES[lang]; + const locale = LOCALES[lang]; + const pluralRules = new Intl.PluralRules(locale); + const number = new Intl.NumberFormat(locale); + + const plural = (forms: PluralForms, count: number, params: Params = {}) => + fmt(pluralRules.select(count) === 'one' ? forms.one : forms.other, { + count: number.format(count), + ...params, + }); + + const formatDate = (value: Date | string | null | undefined, options: Intl.DateTimeFormatOptions = {}) => { + if (!value) return m.common.unknown; + const date = value instanceof Date ? value : new Date(value); + if (isNaN(date.getTime())) return m.common.unknown; + return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', ...options }).format(date); + }; + + const formatBytes = (bytes: number | null | undefined) => { + if (!bytes || bytes < 0) return `0 ${m.units.bytes[0]}`; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), m.units.bytes.length - 1); + const value = bytes / 1024 ** i; + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: i === 0 ? 0 : 1 }).format(value)} ${m.units.bytes[i]}`; + }; + + /** Minutes → "12 h 30 m". */ + const formatDuration = (minutes: number | null | undefined) => { + const total = Math.max(0, Math.round(minutes ?? 0)); + const h = Math.floor(total / 60); + const min = total % 60; + if (h === 0) return `${min} ${m.units.minutes}`; + return min === 0 + ? `${number.format(h)} ${m.units.hours}` + : `${number.format(h)} ${m.units.hours} ${min} ${m.units.minutes}`; + }; + + return { + lang, + locale, + m, + fmt, + plural, + formatDate, + formatBytes, + formatNumber: (n: number) => number.format(n), + formatDuration, + }; +} + +export type I18n = ReturnType<typeof createI18n>; + +export const I18nContext = createContext<I18n>(createI18n('en')); + +export function useI18n(): I18n { + return useContext(I18nContext); +} + +/** Localized message for an error code, falling back to the technical message. */ +export function errorText(i18n: I18n, error: { code: string; message: string }): string { + return (i18n.m.errors.codes as Record<string, string>)[error.code] ?? error.message; +} diff --git a/src/i18n/en.ts b/src/i18n/en.ts new file mode 100644 index 0000000..8a8be1a --- /dev/null +++ b/src/i18n/en.ts @@ -0,0 +1,724 @@ +/** + * English messages. This object defines the shape (type) every other + * language must implement. Placeholders use {name}; plurals use {one, other}. + */ +const en = { + meta: { + siteName: 'KoBup', + tagline: 'Free Kobo backup tool', + pages: { + home: { + title: 'KoBup — Free Kobo Backup & Restore Tool, 100% in Your Browser', + description: + 'Back up and restore your Kobo e-reader: books, highlights, notes and reading progress. Free, private, open source — nothing is uploaded, no account needed.', + }, + backup: { + title: 'Back Up Your Kobo — Books, Highlights & Reading Progress | KoBup', + description: + 'Create a complete backup of your Kobo in minutes: sideloaded books, annotations, reading progress and settings, with optional AES-256 encryption. Runs locally in your browser.', + }, + restore: { + title: 'Restore a Kobo Backup or Move to a New Kobo | KoBup', + description: + 'Restore your library to a reset or new Kobo. Merge reading progress, highlights and collections into the new device without losing its account. Local and private.', + }, + library: { + title: 'Kobo Library Viewer & Highlights Exporter (Obsidian, Anki) | KoBup', + description: + 'Browse your Kobo library with real covers, reading stats and highlights. Export notes to Obsidian Markdown or Anki flashcards — directly from your e-reader.', + }, + history: { + title: 'Backup History | KoBup', + description: + 'Your previous Kobo backups, stored only in this browser. Verify or restore them in one click.', + }, + guide: { + title: 'How to Back Up and Restore a Kobo — Step-by-Step Guide | KoBup', + description: + 'Step-by-step guide to backing up your Kobo e-reader, moving your library to a new Kobo and exporting highlights. Works in Chrome, Edge, Firefox and Safari.', + }, + faq: { + title: 'Kobo Backup FAQ — Privacy, Browsers, Restore | KoBup', + description: + 'Answers about backing up a Kobo: what is included, which browsers work, how restore and encryption work, and why your data never leaves your computer.', + }, + privacy: { + title: 'Privacy Policy | KoBup', + description: + 'KoBup processes your Kobo library only inside your browser. No uploads, no cookies, no accounts. Read exactly what is (and is not) collected.', + }, + notFound: { + title: 'Page not found | KoBup', + description: 'The page you are looking for does not exist.', + }, + }, + }, + + common: { + skipToContent: 'Skip to main content', + close: 'Close', + cancel: 'Cancel', + back: 'Back', + continue: 'Continue', + retry: 'Try again', + done: 'Done', + loading: 'Loading…', + unknown: 'Unknown', + optional: 'Optional', + recommended: 'Recommended', + required: 'Required', + technicalDetails: 'Technical details', + books: { one: '{count} book', other: '{count} books' }, + annotations: { one: '{count} highlight', other: '{count} highlights' }, + files: { one: '{count} file', other: '{count} files' }, + stepOf: 'Step {current} of {total}', + opensInNewTab: '(opens in a new tab)', + }, + + units: { + bytes: ['B', 'KB', 'MB', 'GB', 'TB'], + hours: 'h', + minutes: 'm', + }, + + nav: { + label: 'Main navigation', + home: 'Home', + backup: 'Back up', + restore: 'Restore', + library: 'Library', + history: 'History', + guide: 'Guide', + faq: 'FAQ', + privacy: 'Privacy', + openMenu: 'Open menu', + closeMenu: 'Close menu', + language: 'Language', + switchLanguage: 'Italiano', + switchLanguageLabel: 'Leggi questa pagina in italiano', + logoLabel: 'KoBup home', + }, + + footer: { + about: + 'A free, open-source tool to back up and restore your Kobo e-reader. Everything happens in your browser: your library never leaves your computer.', + resources: 'Resources', + privacyTitle: 'Privacy first', + badges: ['Nothing is uploaded', 'No account, no cookies', 'Open source & auditable'], + github: 'Source code on GitHub', + disclaimer: 'Not affiliated with Rakuten Kobo Inc.', + version: 'Version {version}', + }, + + analytics: { + text: 'This site uses cookieless Vercel Analytics to count anonymous page views. Your Kobo data is never sent anywhere.', + policy: 'Privacy policy', + ok: 'OK', + }, + + browser: { + limitedTitle: 'Limited browser support', + limitedBody: + 'You can create backups and explore your library here. Restoring to a Kobo needs Chrome, Edge or another Chromium-based desktop browser.', + dismiss: 'Dismiss', + unsupportedTitle: 'Browser not supported', + unsupportedBody: + 'Your browser lacks WebAssembly, which KoBup needs to read the Kobo database. Please update your browser.', + restoreNeedsChromium: + 'Restoring writes files to your Kobo, which is only possible in Chrome, Edge, Opera or Brave on a computer.', + }, + + connect: { + title: 'Connect your Kobo', + subtitle: 'Three quick steps and KoBup reads your library — locally.', + steps: [ + { title: 'Plug it in', body: 'Connect your Kobo to the computer with a USB data cable.' }, + { title: 'Tap “Connect”', body: 'Unlock the Kobo and tap “Connect” on its screen.' }, + { title: 'Choose the drive', body: 'Select the Kobo drive, usually named “KOBOeReader”.' }, + ], + selectButton: 'Select Kobo drive', + selecting: 'Waiting for your selection…', + folderButton: 'Select Kobo folder', + folderHint: + 'Your browser will say it is “uploading” files: it is only reading them locally. Nothing leaves your computer.', + driveHint: 'Select the root of the Kobo drive (the folder containing .kobo).', + connected: 'Connected: {model}', + disconnect: 'Use a different Kobo', + }, + + scanning: { + title: 'Reading your Kobo', + steps: ['Reading the database', 'Analysing books and highlights', 'Finding book files', 'Done'], + hint: 'This takes a few seconds, depending on the size of your library.', + }, + + warnings: { + walPending: + 'Your Kobo has changes that are not yet saved in its main database (for example the latest reading progress). For a complete backup, eject the Kobo, wait a few seconds, reconnect it and scan again.', + }, + + home: { + heroTitle: 'Back up your Kobo library.', + heroHighlight: 'Keep every highlight.', + heroBody: + 'Free, private backup for your books, highlights, notes and reading progress. It runs entirely in your browser: nothing is uploaded, no account needed.', + ctaBackup: 'Create a backup', + ctaRestore: 'Restore a backup', + ctaLibrary: 'Explore your library', + trust: ['No installation', 'Nothing uploaded', 'Open source'], + featuresTitle: 'Everything your Kobo needs', + features: [ + { + title: 'Complete backup', + body: 'Sideloaded books, the Kobo database with progress and annotations, plus settings, fonts and sleep screens — in one ZIP.', + }, + { + title: 'Private by design', + body: 'Your library is processed locally with WebAssembly. A strict security policy stops the page from sending your data anywhere.', + }, + { + title: 'Optional encryption', + body: 'Protect the backup with AES-256. Useful because the Kobo database also contains your account sign-in tokens.', + }, + { + title: 'Move to a new Kobo', + body: 'Merge reading progress, highlights and collections into the new device while keeping its account and settings.', + }, + { + title: 'Library & exports', + body: 'Browse your books with real covers and stats. Export highlights to Obsidian Markdown or Anki flashcards.', + }, + { + title: 'Verified and reversible', + body: 'Every backup is read back and checksummed. Before restoring, the current database is saved so you can undo.', + }, + ], + howTitle: 'How it works', + how: [ + { + title: 'Connect your Kobo', + body: 'Plug it in via USB and tap “Connect”. It appears as a drive on your computer.', + }, + { title: 'Select the drive', body: 'KoBup reads your library locally and shows what it found.' }, + { + title: 'Save the backup', + body: 'Choose where to save the ZIP. Keep a copy on another disk or in the cloud.', + }, + ], + faqTitle: 'Frequently asked questions', + faqMore: 'All questions', + }, + + backup: { + title: 'Back up your Kobo', + stepLabels: ['Connect', 'Review', 'Options', 'Backup'], + overviewTitle: 'Your library', + overviewSubtitle: 'Here is what KoBup found on your {model}.', + stats: { + books: 'Books', + annotations: 'Highlights & notes', + size: 'Estimated size', + finished: 'Finished', + started: 'Started', + reading: 'Reading now', + timeRead: 'Time spent reading', + }, + recent: 'Recently read', + more: { one: 'and {count} more book', other: 'and {count} more books' }, + toOptions: 'Choose what to back up', + optionsTitle: 'What to include', + optionsSubtitle: 'The Kobo database (reading progress, highlights, collections) is always included.', + includeBooks: 'Book files ({count})', + includeBooksHint: 'Uncheck for a small, database-only backup of progress and highlights.', + includeAnnotations: 'Readable copy of highlights', + includeAnnotationsHint: 'Adds a Markdown file with all your highlights and notes, readable anywhere.', + includeSettings: 'Device settings, fonts and sleep screens ({count})', + includeSettingsHint: 'Reading preferences, sideloaded fonts and custom screensavers.', + encryptTitle: 'Protect with a password (AES-256)', + encryptHint: + 'Recommended if you store the backup in the cloud: the database contains your Kobo account tokens. Without the password the backup cannot be restored.', + password: 'Password', + passwordConfirm: 'Confirm password', + passwordTooShort: 'Use at least 8 characters.', + passwordMismatch: 'The passwords do not match.', + estimated: 'Estimated backup size: {size}', + diskSpace: 'Make sure you have enough free disk space.', + start: 'Start backup', + saveDialogHint: 'Your browser will ask where to save the file.', + progressTitle: 'Creating your backup', + keepOpen: 'Keep this tab open until the backup is complete.', + stages: { + preparing: 'Preparing…', + books: 'Adding books ({current}/{total})…', + settings: 'Adding device settings…', + annotations: 'Exporting highlights…', + metadata: 'Writing backup information…', + finalizing: 'Finalizing…', + verifying: 'Verifying the backup…', + complete: 'Backup complete', + }, + successTitle: 'Backup complete', + successBody: 'Your Kobo library is safely backed up.', + verifiedOk: 'Verified: the archive was read back and the database checksum matches.', + verifiedFail: 'Verification failed: the saved file may be incomplete. Please create the backup again.', + encryptedNote: 'This backup is encrypted. Keep the password safe: it cannot be recovered.', + downloadNote: 'The file was downloaded by your browser (usually to the Downloads folder).', + savedAs: 'Saved as {filename}', + summary: { file: 'File', size: 'Size', books: 'Books', annotations: 'Highlights' }, + nextTitle: 'Keep it safe', + next: [ + 'Copy it to an external drive or a cloud folder', + 'Follow the 3-2-1 rule: 3 copies, 2 different media, 1 off-site', + 'Create a new backup after big reading sessions or before firmware updates', + ], + another: 'Back up another Kobo', + openLibrary: 'Open library', + failedTitle: 'Backup failed', + failedBody: 'The backup could not be completed.', + failedHint: 'With very large libraries, close other tabs to free memory and try again.', + }, + + restore: { + title: 'Restore a backup', + stepLabels: ['Backup file', 'Kobo', 'Review', 'Restore'], + fileTitle: 'Choose a backup file', + fileSubtitle: 'Select a KoBup backup (kobo_backup_*.zip).', + drop: 'Drag and drop the backup here', + or: 'or', + browse: 'Browse files', + reading: 'Reading the backup…', + largeFile: 'Large backup: restoring may take a while. Keep this tab open.', + passwordTitle: 'This backup is password-protected', + passwordLabel: 'Backup password', + unlock: 'Unlock backup', + deviceTitle: 'Choose the Kobo to restore to', + deviceSubtitle: 'Connect the Kobo that should receive the backup and select its drive.', + deviceWarning: + 'Restoring changes the database on this Kobo. A copy of the current database is saved first so you can undo.', + selectDevice: 'Select Kobo drive', + reviewTitle: 'Review and choose how to restore', + backupDetails: 'Backup', + targetDevice: 'Target Kobo', + created: 'Created', + device: 'Device', + firmware: 'Firmware', + books: 'Books', + annotations: 'Highlights', + readingTime: 'Reading time', + finished: 'Finished', + encrypted: 'Encrypted', + integrityOk: 'Backup integrity verified (database checksum matches).', + integrityBad: + 'Warning: the database in this backup does not match its checksum. The file may be damaged.', + compatibility: { + title: 'Compatibility', + ok: 'No compatibility issues found.', + model: 'The backup comes from a {from}, this is a {to}. Reading data transfers fine; settings may not.', + firmware: 'Different firmware ({from} → {to}). This normally works.', + 'schema-newer': + 'The backup was made with newer Kobo software (database version {from}) than this Kobo has ({to}). Update this Kobo first.', + 'old-backup': 'This backup is {days} days old.', + }, + modeTitle: 'How to restore', + modes: { + merge: { + title: 'Merge reading data', + badge: 'Recommended', + body: 'Adds progress, highlights and collections of your books to this Kobo’s own database. Keeps this Kobo’s account, store books and settings. Best when moving to a new or reset Kobo.', + }, + full: { + title: 'Replace everything', + body: 'Makes this Kobo’s database an exact copy of the backup, including the old account sign-in and store library. Best for the same Kobo after a factory reset.', + }, + }, + mergeWhat: 'Include', + mergeProgress: 'Reading progress', + mergeAnnotations: 'Highlights, notes and bookmarks', + mergeCollections: 'Collections', + filesTitle: 'Files', + restoreBooks: 'Book files ({count})', + restoreBooksHint: 'Copies the books back to their original folders.', + noBooksInBackup: 'This is a database-only backup: no book files will be copied.', + restoreSettings: 'Device settings, fonts and sleep screens ({count})', + restoreSettingsHint: 'Best on the same Kobo model.', + cleanFolders: 'Delete existing book folders first', + cleanFoldersHint: + 'Removes the device’s current book folders before copying. Books you added after the backup will be lost.', + confirm: 'I understand that the database on this Kobo will be changed', + start: 'Start restore', + progressTitle: 'Restoring your library', + doNotDisconnect: 'Do not disconnect your Kobo until the restore is complete.', + stages: { + preparing: 'Preparing the Kobo…', + snapshot: 'Saving a copy of the current database…', + cleaning: 'Removing existing book folders…', + merging: 'Merging reading data…', + database: 'Writing the database…', + books: 'Copying books ({current}/{total})…', + settings: 'Restoring device settings…', + verifying: 'Verifying…', + complete: 'Restore complete', + }, + successTitle: 'Restore complete', + partialTitle: 'Restore completed with warnings', + summary: { + books: 'Books copied', + merged: 'Books updated', + added: 'Books added', + annotations: 'Highlights added', + collections: 'Collections added', + settings: 'Settings files', + }, + failedFiles: { one: '{count} file could not be restored', other: '{count} files could not be restored' }, + verifyMismatch: + 'The restored database lists {actual} books, the backup had {expected}. Eject and reconnect the Kobo to let it rescan.', + snapshotNote: 'The previous database was saved on the Kobo as .kobo/KoboReader.sqlite.before-restore.', + undo: 'Undo this restore', + undoConfirm: 'Put back the database this Kobo had before the restore?', + undone: 'The previous database has been restored.', + nextTitle: 'Finish up', + next: [ + 'Eject the Kobo safely from your computer', + 'Unplug the USB cable and let the Kobo process the changes', + 'Open your books and continue where you left off', + ], + failedTitle: 'Restore failed', + failedBody: 'The restore could not be completed.', + failedDbWritten: + 'The database may already have been changed. You can undo to the copy saved before the restore.', + }, + + library: { + title: 'Your Kobo library', + subtitle: 'Browse books, highlights and reading stats — straight from your e-reader.', + connected: '{model} · firmware {firmware}', + backupCta: 'Back up this library', + tabs: { books: 'Books', annotations: 'Highlights', collections: 'Collections', stats: 'Statistics' }, + search: 'Search books or authors', + filter: 'Filter', + sort: 'Sort by', + filters: { all: 'All books', reading: 'Reading', finished: 'Finished', unread: 'Unread' }, + sorts: { recent: 'Recently read', title: 'Title', author: 'Author', progress: 'Progress' }, + results: { one: '{count} book', other: '{count} books' }, + noResults: 'No books match your search.', + read: '{percent}% read', + details: 'Book details', + timeRead: 'Time spent reading', + format: 'Format', + isbn: 'ISBN', + publisher: 'Publisher', + series: 'Series', + lastRead: 'Last read', + never: 'Never', + noAnnotationsForBook: 'No highlights or notes for this book yet.', + exportTitle: 'Export your highlights', + exportBody: 'Take your notes to your favourite tools.', + exportObsidian: 'Obsidian (Markdown ZIP)', + exportAnki: 'Anki (CSV)', + exportBook: 'Export to Markdown', + exportFailed: 'Export failed: {error}', + noAnnotations: 'No highlights found. Highlight passages on your Kobo and they will appear here.', + note: 'Note', + copy: 'Copy', + copied: 'Copied', + noCollections: 'No collections on this Kobo.', + statsOverview: 'Reading overview', + progressBreakdown: 'Progress', + topAuthors: 'Top authors', + unreadLabel: 'Unread', + readingLabel: 'In progress', + finishedLabel: 'Finished', + }, + + history: { + title: 'Backup history', + subtitle: 'Backups created in this browser. The list is stored only on this computer.', + emptyTitle: 'No backups yet', + emptyBody: 'Create your first backup to keep your Kobo library safe.', + create: 'Create a backup', + encrypted: 'Encrypted', + verified: 'Verified', + verify: 'Verify', + verifying: 'Verifying…', + verifyOk: 'The file is intact.', + verifyBad: 'Problems found: {details}', + restore: 'Restore', + remove: 'Remove from history', + removeTitle: 'Remove this backup from the history?', + removeBody: 'Only the history entry is removed. The backup file on your disk is not deleted.', + notAvailable: + 'The file could not be opened (moved, renamed or permission denied). Use “Restore” and pick it manually.', + bestTitle: 'Good backup habits', + best: [ + 'Back up regularly, especially before firmware updates', + 'Keep copies in more than one place (cloud + external drive)', + 'Test a backup now and then by verifying it', + ], + }, + + errors: { + genericTitle: 'Something went wrong', + genericBody: 'An unexpected error occurred. Your data is safe.', + reload: 'Reload the page', + report: 'Report the problem on GitHub', + codes: { + INVALID_DEVICE: + 'This folder is not a Kobo. Select the root of the Kobo drive (it contains a .kobo folder).', + FS_NOT_SUPPORTED: + 'This browser cannot access folders directly. Use Chrome, Edge or Opera on a computer.', + FS_PERMISSION_DENIED: 'Permission denied. Allow access to the Kobo when your browser asks.', + FS_NOT_FOUND: 'A required file was not found on the Kobo.', + FS_READ_ERROR: 'A file on the Kobo could not be read. Reconnect the Kobo and try again.', + FS_WRITE_ERROR: 'Writing to the Kobo failed. Check it is still connected and has free space.', + DB_OPEN_FAILED: 'The Kobo database could not be opened.', + DB_QUERY_FAILED: 'The Kobo database could not be read.', + DB_WRITE_ERROR: 'The database could not be prepared for the Kobo.', + BACKUP_FAILED: 'The backup could not be created.', + RESTORE_INVALID_FILE: 'This file is not a valid KoBup backup.', + RESTORE_CORRUPTED: 'The backup file is damaged or incomplete.', + RESTORE_PASSWORD_REQUIRED: 'This backup is encrypted: enter its password.', + RESTORE_WRONG_PASSWORD: 'Wrong password. Try again.', + RESTORE_DEVICE_BUSY: + 'The Kobo database has unsaved changes. Eject the Kobo, wait a few seconds, reconnect it and try again.', + RESTORE_FAILED: 'The restore could not be completed.', + SCAN_FAILED: 'The Kobo could not be read.', + }, + }, + + notFound: { + title: 'Page not found', + body: 'The page you are looking for does not exist or has moved.', + home: 'Go to the home page', + }, + + guide: { + title: 'How to back up and restore your Kobo', + intro: + 'KoBup works entirely in your browser. This guide walks you through a backup, a restore to the same or a new Kobo, and exporting your highlights.', + requirementsTitle: 'What you need', + requirements: [ + 'A computer with Chrome, Edge, Opera or Brave (backup and restore)', + 'Firefox or Safari work for backups and the library, but cannot restore', + 'A USB data cable (some cables only charge)', + ], + sections: [ + { + id: 'backup', + title: 'Create a backup', + steps: [ + 'Connect your Kobo with the USB cable, unlock it and tap “Connect”.', + 'Open “Back up” and select the Kobo drive (usually “KOBOeReader”).', + 'Check the summary of books and highlights KoBup found.', + 'Choose what to include. Add a password if you will store the backup in the cloud.', + 'Pick where to save the ZIP. KoBup writes it and then verifies it.', + ], + }, + { + id: 'new-kobo', + title: 'Move your library to a new Kobo', + steps: [ + 'Set up the new Kobo and sign in to your Kobo account.', + 'Connect it, open “Restore” and select your backup file.', + 'Select the new Kobo drive.', + 'Choose “Merge reading data”: your progress, highlights and collections are added while the new Kobo keeps its account.', + 'Start the restore, then eject the Kobo safely.', + ], + }, + { + id: 'reset', + title: 'Restore the same Kobo after a reset', + steps: [ + 'Connect the Kobo and open “Restore”.', + 'Select the backup and the Kobo drive.', + 'Choose “Replace everything” for an exact copy of the backed-up database, or “Merge” to keep the current account.', + 'If something looks wrong afterwards, use “Undo this restore”: the previous database was saved on the Kobo.', + ], + }, + { + id: 'export', + title: 'Export highlights to Obsidian or Anki', + steps: [ + 'Open “Library” and connect your Kobo.', + 'Go to the “Highlights” tab.', + 'Export everything to Obsidian (one Markdown note per book) or to Anki (CSV flashcards), or export a single book.', + ], + }, + ], + troubleshootingTitle: 'Troubleshooting', + troubleshooting: [ + { + q: 'The Kobo does not appear as a drive', + a: 'Use a data-capable USB cable, unlock the Kobo and tap “Connect”. On Windows, check File Explorer for a drive named KOBOeReader.', + }, + { + q: '“This folder is not a Kobo”', + a: 'Select the root of the drive, not a subfolder. The root contains a hidden “.kobo” folder.', + }, + { + q: 'The backup is slow', + a: 'Speed depends on the Kobo’s storage and the USB connection. Libraries of several gigabytes can take a few minutes.', + }, + { + q: 'Books show up without progress after a restore', + a: 'Books must be restored to their original folders. Let the Kobo finish processing after you eject it; if needed, reconnect it once.', + }, + ], + }, + + faq: { + title: 'Frequently asked questions', + intro: 'Everything about backing up and restoring your Kobo e-reader with KoBup.', + categories: [ + { + title: 'Getting started', + items: [ + { + q: 'What is KoBup?', + a: 'KoBup is a free, open-source web tool that backs up and restores Kobo e-readers directly in your browser. It reads your books, highlights and reading progress locally and saves them into a ZIP file on your computer.', + }, + { + q: 'Which browsers are supported?', + a: 'Backups and the library work in all modern desktop browsers. Restoring needs the File System Access API to write to the Kobo, available in Chrome, Edge, Opera and Brave on a computer.', + }, + { + q: 'Do I need an account?', + a: 'No. There is no registration and no login. KoBup has no server that could store your data.', + }, + { + q: 'Is it free?', + a: 'Yes, completely. It is a personal open-source project under the MIT license.', + }, + ], + }, + { + title: 'Backup & restore', + items: [ + { + q: 'What does a backup contain?', + a: 'The Kobo database (reading progress, highlights, notes, bookmarks, collections) and, optionally, your sideloaded book files, reading settings, custom fonts and sleep screens. Books bought from the Kobo store are not included: you can download them again from your account.', + }, + { + q: 'Can I move my library to a new Kobo?', + a: 'Yes. Choose “Merge reading data” when restoring: progress, highlights and collections are added to the new Kobo while it keeps its own account and settings.', + }, + { + q: 'Will restoring delete my current books?', + a: 'No, unless you explicitly choose to delete the existing book folders. Before changing the database, KoBup saves a copy of it on the Kobo so you can undo the restore.', + }, + { + q: 'How do I know a backup is good?', + a: 'Right after writing, KoBup reads the archive back, checks every file is there and compares the database checksum. You can verify it again later from the History page.', + }, + { + q: 'How long does it take?', + a: 'A database-only backup takes seconds. A full backup depends on your library size and USB speed: a few minutes for several gigabytes.', + }, + ], + }, + { + title: 'Privacy & security', + items: [ + { + q: 'Is my data sent to a server?', + a: 'No. Everything runs in your browser with JavaScript and WebAssembly. The site’s Content Security Policy only allows connections to its own origin, so the page cannot upload your library.', + }, + { + q: 'Why would I encrypt a backup?', + a: 'The Kobo database also stores your Kobo account sign-in tokens. If you keep backups in the cloud, a password (AES-256) makes sure nobody else can use them.', + }, + { + q: 'Does KoBup use analytics or cookies?', + a: 'No cookies. The site uses cookieless Vercel Analytics to count anonymous page views; it never sees your library or backup files.', + }, + { + q: 'Is it affiliated with Rakuten Kobo?', + a: 'No. KoBup is an independent community tool, not endorsed by Rakuten Kobo Inc.', + }, + ], + }, + { + title: 'Technical', + items: [ + { + q: 'Does it work offline?', + a: 'Yes. After the first visit the app is cached and can be installed, so it keeps working without an internet connection.', + }, + { + q: 'Can I use it on a phone or tablet?', + a: 'Not really: mobile browsers cannot access a USB-connected Kobo. Use a desktop or laptop computer.', + }, + { + q: 'Why are my book covers missing?', + a: 'Covers come from the thumbnails your Kobo generated. Books you never opened in the library view may not have one yet; KoBup then draws a placeholder.', + }, + ], + }, + ], + moreTitle: 'Still have questions?', + moreBody: 'Read the step-by-step guide or open an issue on GitHub.', + moreGuide: 'Read the guide', + }, + + privacy: { + title: 'Privacy policy', + updated: 'Last updated: {date}', + intro: + 'KoBup is a free, non-commercial, open-source tool. It is built so that your Kobo library never leaves your computer. This page explains exactly what is processed and where.', + sections: [ + { + title: 'Your Kobo data stays on your computer', + paragraphs: [ + 'When you create or restore a backup, or open the library, KoBup reads the Kobo database and files directly in your browser. Nothing is uploaded: there is no KoBup server that receives data.', + 'This is enforced by a Content Security Policy that only allows the page to connect to its own origin.', + ], + }, + { + title: 'Data stored in your browser', + paragraphs: [ + 'KoBup stores a small amount of data locally, which you can delete at any time by clearing site data:', + ], + items: [ + 'Backup history (date, size, number of books) in local storage', + 'References to saved backup files in IndexedDB, used to verify or restore them later (no file content)', + 'Whether you dismissed the analytics notice', + ], + }, + { + title: 'Anonymous page views (Vercel Analytics)', + paragraphs: [ + 'The hosted site at kobup.org uses Vercel Analytics to count page views. It sets no cookies and does not track you across sites. It records the page address, referrer, approximate country, browser and device type. It never receives your library or backups.', + 'Self-hosted copies of KoBup can run without any analytics.', + ], + }, + { + title: 'No third-party requests', + paragraphs: [ + 'Fonts and all other assets are served from kobup.org itself. The site does not load anything from Google or other third parties.', + ], + }, + { + title: 'Legal basis and your rights (GDPR)', + paragraphs: [ + 'Local processing is needed to provide the tool you use. Aggregate, cookieless analytics rely on legitimate interest (Art. 6(1)(f) GDPR). You can block them with any content blocker.', + 'Because KoBup does not hold personal data about you, there is nothing to access, correct or delete on our side. Questions can be asked through GitHub issues.', + ], + }, + { + title: 'Changes', + paragraphs: [ + 'If this policy changes, the date at the top will be updated. The full history is public on GitHub.', + ], + }, + ], + }, +}; + +export default en; + +type Widen<T> = T extends string + ? string + : T extends readonly (infer U)[] + ? Widen<U>[] + : { [K in keyof T]: Widen<T[K]> }; + +export type Messages = Widen<typeof en>; diff --git a/src/i18n/i18n.test.ts b/src/i18n/i18n.test.ts new file mode 100644 index 0000000..ce4c767 --- /dev/null +++ b/src/i18n/i18n.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { createI18n, fmt, MESSAGES } from './core.ts'; + +type Tree = string | Tree[] | { [key: string]: Tree }; + +function leaves(tree: Tree, path = ''): [string, string][] { + if (typeof tree === 'string') return [[path, tree]]; + if (Array.isArray(tree)) return tree.flatMap((t, i) => leaves(t, `${path}[${i}]`)); + return Object.entries(tree).flatMap(([k, v]) => leaves(v, path ? `${path}.${k}` : k)); +} + +const placeholders = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort(); + +describe('translations', () => { + const en = new Map(leaves(MESSAGES.en as unknown as Tree)); + const it_ = new Map(leaves(MESSAGES.it as unknown as Tree)); + + it('Italian has exactly the same keys as English', () => { + expect([...it_.keys()].sort()).toEqual([...en.keys()].sort()); + }); + + it('no translation is empty', () => { + for (const [key, value] of it_) expect(value.trim(), key).not.toBe(''); + }); + + it('placeholders match between languages', () => { + for (const [key, value] of en) { + // Plural "one" forms may omit {count} (e.g. "e un altro libro"). + if (key.endsWith('.one')) continue; + expect(placeholders(it_.get(key)!), key).toEqual(placeholders(value)); + } + }); + + it('SEO titles and descriptions have search-friendly lengths', () => { + for (const lang of ['en', 'it'] as const) { + for (const [id, page] of Object.entries(MESSAGES[lang].meta.pages)) { + if (id === 'notFound') continue; // noindex + expect(page.title.length, `${lang}.${id}.title`).toBeLessThanOrEqual(90); + expect(page.description.length, `${lang}.${id}.description`).toBeLessThanOrEqual(200); + expect(page.description.length, `${lang}.${id}.description`).toBeGreaterThan(40); + } + } + }); +}); + +describe('formatting helpers', () => { + it('interpolates placeholders and leaves unknown ones', () => { + expect(fmt('{a} and {b}', { a: 1 })).toBe('1 and {b}'); + }); + + it('pluralizes and formats per locale', () => { + const en = createI18n('en'); + const it = createI18n('it'); + expect(en.plural(en.m.common.books, 1)).toBe('1 book'); + expect(en.plural(en.m.common.books, 1234)).toBe('1,234 books'); + expect(it.plural(it.m.common.books, 12345)).toBe('12.345 libri'); + expect(it.formatDuration(1419)).toBe('23 h 39 min'); + expect(en.formatBytes(1536)).toBe('1.5 KB'); + expect(it.formatBytes(1536)).toBe('1,5 KB'); + }); +}); diff --git a/src/i18n/index.tsx b/src/i18n/index.tsx new file mode 100644 index 0000000..249b3f2 --- /dev/null +++ b/src/i18n/index.tsx @@ -0,0 +1,8 @@ +import { useMemo, type ReactNode } from 'react'; +import { createI18n, I18nContext } from './core.ts'; +import type { Lang } from '../router/routes.ts'; + +export function I18nProvider({ lang, children }: { lang: Lang; children: ReactNode }) { + const value = useMemo(() => createI18n(lang), [lang]); + return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>; +} diff --git a/src/i18n/it.ts b/src/i18n/it.ts new file mode 100644 index 0000000..bd5dea9 --- /dev/null +++ b/src/i18n/it.ts @@ -0,0 +1,730 @@ +import type { Messages } from './en.ts'; + +const it: Messages = { + meta: { + siteName: 'KoBup', + tagline: 'Backup Kobo gratuito', + pages: { + home: { + title: 'KoBup — Backup e ripristino Kobo gratis, direttamente nel browser', + description: + 'Fai il backup del tuo e-reader Kobo e ripristinalo: libri, evidenziazioni, note e avanzamento di lettura. Gratuito, privato e open source: nessun upload, nessun account.', + }, + backup: { + title: 'Backup del Kobo — libri, evidenziazioni e avanzamento | KoBup', + description: + 'Crea in pochi minuti un backup completo del tuo Kobo: libri caricati, annotazioni, avanzamento di lettura e impostazioni, con cifratura AES-256 opzionale. Tutto in locale nel browser.', + }, + restore: { + title: 'Ripristina un backup Kobo o passa a un nuovo Kobo | KoBup', + description: + 'Ripristina la libreria su un Kobo resettato o nuovo. Unisci avanzamento, evidenziazioni e raccolte nel nuovo dispositivo senza perdere il suo account. Locale e privato.', + }, + library: { + title: 'Libreria Kobo ed esportazione evidenziazioni (Obsidian, Anki) | KoBup', + description: + 'Sfoglia la libreria del Kobo con copertine vere, statistiche di lettura ed evidenziazioni. Esporta le note in Markdown per Obsidian o in flashcard Anki.', + }, + history: { + title: 'Cronologia backup | KoBup', + description: + 'I tuoi backup Kobo precedenti, salvati solo in questo browser. Verificali o ripristinali con un clic.', + }, + guide: { + title: 'Come fare il backup e il ripristino di un Kobo — Guida passo passo | KoBup', + description: + 'Guida passo passo per fare il backup del Kobo, trasferire la libreria su un nuovo Kobo ed esportare le evidenziazioni. Funziona con Chrome, Edge, Firefox e Safari.', + }, + faq: { + title: 'Domande frequenti sul backup Kobo — privacy, browser, ripristino | KoBup', + description: + 'Risposte sul backup del Kobo: cosa include, quali browser funzionano, come funzionano ripristino e cifratura e perché i tuoi dati non lasciano mai il computer.', + }, + privacy: { + title: 'Informativa privacy | KoBup', + description: + 'KoBup elabora la tua libreria Kobo solo nel browser. Nessun upload, nessun cookie, nessun account. Ecco cosa viene raccolto (e cosa no).', + }, + notFound: { + title: 'Pagina non trovata | KoBup', + description: 'La pagina che cerchi non esiste.', + }, + }, + }, + + common: { + skipToContent: 'Vai al contenuto principale', + close: 'Chiudi', + cancel: 'Annulla', + back: 'Indietro', + continue: 'Continua', + retry: 'Riprova', + done: 'Fatto', + loading: 'Caricamento…', + unknown: 'Sconosciuto', + optional: 'Facoltativo', + recommended: 'Consigliato', + required: 'Obbligatorio', + technicalDetails: 'Dettagli tecnici', + books: { one: '{count} libro', other: '{count} libri' }, + annotations: { one: '{count} evidenziazione', other: '{count} evidenziazioni' }, + files: { one: '{count} file', other: '{count} file' }, + stepOf: 'Passo {current} di {total}', + opensInNewTab: '(si apre in una nuova scheda)', + }, + + units: { + bytes: ['B', 'KB', 'MB', 'GB', 'TB'], + hours: 'h', + minutes: 'min', + }, + + nav: { + label: 'Navigazione principale', + home: 'Home', + backup: 'Backup', + restore: 'Ripristino', + library: 'Libreria', + history: 'Cronologia', + guide: 'Guida', + faq: 'FAQ', + privacy: 'Privacy', + openMenu: 'Apri il menu', + closeMenu: 'Chiudi il menu', + language: 'Lingua', + switchLanguage: 'English', + switchLanguageLabel: 'Read this page in English', + logoLabel: 'KoBup, home page', + }, + + footer: { + about: + 'Uno strumento gratuito e open source per fare il backup e il ripristino del tuo e-reader Kobo. Tutto avviene nel browser: la tua libreria non lascia mai il computer.', + resources: 'Risorse', + privacyTitle: 'Privacy prima di tutto', + badges: ['Nessun upload', 'Nessun account, nessun cookie', 'Open source e verificabile'], + github: 'Codice sorgente su GitHub', + disclaimer: 'Non affiliato con Rakuten Kobo Inc.', + version: 'Versione {version}', + }, + + analytics: { + text: 'Questo sito usa Vercel Analytics senza cookie per contare le visite in forma anonima. I dati del tuo Kobo non vengono mai inviati.', + policy: 'Informativa privacy', + ok: 'OK', + }, + + browser: { + limitedTitle: 'Supporto del browser limitato', + limitedBody: + 'Qui puoi creare backup ed esplorare la libreria. Per ripristinare un backup sul Kobo serve Chrome, Edge o un altro browser Chromium per computer.', + dismiss: 'Chiudi', + unsupportedTitle: 'Browser non supportato', + unsupportedBody: + 'Il tuo browser non supporta WebAssembly, necessario a KoBup per leggere il database del Kobo. Aggiorna il browser.', + restoreNeedsChromium: + 'Il ripristino scrive file sul Kobo: è possibile solo con Chrome, Edge, Opera o Brave su computer.', + }, + + connect: { + title: 'Collega il tuo Kobo', + subtitle: 'Tre passaggi e KoBup legge la tua libreria, in locale.', + steps: [ + { title: 'Collegalo', body: 'Collega il Kobo al computer con un cavo USB dati.' }, + { title: 'Tocca «Connetti»', body: 'Sblocca il Kobo e tocca «Connetti» sullo schermo.' }, + { title: 'Scegli l’unità', body: 'Seleziona l’unità del Kobo, di solito chiamata «KOBOeReader».' }, + ], + selectButton: 'Seleziona l’unità del Kobo', + selecting: 'In attesa della selezione…', + folderButton: 'Seleziona la cartella del Kobo', + folderHint: + 'Il browser dirà che sta «caricando» i file: in realtà li legge soltanto in locale. Niente lascia il tuo computer.', + driveHint: 'Seleziona la radice dell’unità del Kobo (la cartella che contiene .kobo).', + connected: 'Collegato: {model}', + disconnect: 'Usa un altro Kobo', + }, + + scanning: { + title: 'Lettura del Kobo', + steps: [ + 'Lettura del database', + 'Analisi di libri ed evidenziazioni', + 'Ricerca dei file dei libri', + 'Fatto', + ], + hint: 'Richiede qualche secondo, in base alle dimensioni della libreria.', + }, + + warnings: { + walPending: + 'Il tuo Kobo ha modifiche non ancora salvate nel database principale (ad esempio l’ultimo avanzamento di lettura). Per un backup completo espelli il Kobo, attendi qualche secondo, ricollegalo e ripeti la scansione.', + }, + + home: { + heroTitle: 'Fai il backup della tua libreria Kobo.', + heroHighlight: 'Non perdere nessuna evidenziazione.', + heroBody: + 'Backup gratuito e privato di libri, evidenziazioni, note e avanzamento di lettura. Funziona interamente nel browser: nessun upload, nessun account.', + ctaBackup: 'Crea un backup', + ctaRestore: 'Ripristina un backup', + ctaLibrary: 'Esplora la libreria', + trust: ['Nessuna installazione', 'Nessun upload', 'Open source'], + featuresTitle: 'Tutto ciò che serve al tuo Kobo', + features: [ + { + title: 'Backup completo', + body: 'Libri caricati, database del Kobo con avanzamento e annotazioni, più impostazioni, font e salvaschermi: tutto in un unico ZIP.', + }, + { + title: 'Privato per progettazione', + body: 'La libreria viene elaborata in locale con WebAssembly. Una politica di sicurezza rigorosa impedisce alla pagina di inviare i tuoi dati.', + }, + { + title: 'Cifratura opzionale', + body: 'Proteggi il backup con AES-256. Utile perché il database del Kobo contiene anche i token di accesso al tuo account.', + }, + { + title: 'Passa a un nuovo Kobo', + body: 'Unisci avanzamento, evidenziazioni e raccolte nel nuovo dispositivo mantenendo il suo account e le sue impostazioni.', + }, + { + title: 'Libreria ed esportazioni', + body: 'Sfoglia i libri con le copertine vere e le statistiche. Esporta le evidenziazioni in Markdown per Obsidian o in flashcard Anki.', + }, + { + title: 'Verificato e reversibile', + body: 'Ogni backup viene riletto e controllato. Prima del ripristino il database attuale viene salvato, così puoi annullare.', + }, + ], + howTitle: 'Come funziona', + how: [ + { + title: 'Collega il Kobo', + body: 'Collegalo via USB e tocca «Connetti»: compare come unità sul computer.', + }, + { title: 'Seleziona l’unità', body: 'KoBup legge la libreria in locale e ti mostra cosa ha trovato.' }, + { + title: 'Salva il backup', + body: 'Scegli dove salvare lo ZIP. Tienine una copia su un altro disco o nel cloud.', + }, + ], + faqTitle: 'Domande frequenti', + faqMore: 'Tutte le domande', + }, + + backup: { + title: 'Backup del tuo Kobo', + stepLabels: ['Collegamento', 'Riepilogo', 'Opzioni', 'Backup'], + overviewTitle: 'La tua libreria', + overviewSubtitle: 'Ecco cosa ha trovato KoBup sul tuo {model}.', + stats: { + books: 'Libri', + annotations: 'Evidenziazioni e note', + size: 'Dimensione stimata', + finished: 'Finiti', + started: 'Iniziati', + reading: 'In lettura', + timeRead: 'Tempo di lettura', + }, + recent: 'Letti di recente', + more: { one: 'e un altro libro', other: 'e altri {count} libri' }, + toOptions: 'Scegli cosa salvare', + optionsTitle: 'Cosa includere', + optionsSubtitle: 'Il database del Kobo (avanzamento, evidenziazioni, raccolte) è sempre incluso.', + includeBooks: 'File dei libri ({count})', + includeBooksHint: + 'Deseleziona per un backup leggero del solo database, con avanzamento ed evidenziazioni.', + includeAnnotations: 'Copia leggibile delle evidenziazioni', + includeAnnotationsHint: + 'Aggiunge un file Markdown con tutte le evidenziazioni e le note, leggibile ovunque.', + includeSettings: 'Impostazioni, font e salvaschermi ({count})', + includeSettingsHint: 'Preferenze di lettura, font caricati e salvaschermi personalizzati.', + encryptTitle: 'Proteggi con una password (AES-256)', + encryptHint: + 'Consigliato se conservi il backup nel cloud: il database contiene i token del tuo account Kobo. Senza password il backup non può essere ripristinato.', + password: 'Password', + passwordConfirm: 'Conferma password', + passwordTooShort: 'Usa almeno 8 caratteri.', + passwordMismatch: 'Le password non coincidono.', + estimated: 'Dimensione stimata del backup: {size}', + diskSpace: 'Assicurati di avere abbastanza spazio libero sul disco.', + start: 'Avvia il backup', + saveDialogHint: 'Il browser ti chiederà dove salvare il file.', + progressTitle: 'Creazione del backup', + keepOpen: 'Tieni aperta questa scheda fino al termine del backup.', + stages: { + preparing: 'Preparazione…', + books: 'Aggiunta dei libri ({current}/{total})…', + settings: 'Aggiunta delle impostazioni…', + annotations: 'Esportazione delle evidenziazioni…', + metadata: 'Scrittura delle informazioni del backup…', + finalizing: 'Completamento…', + verifying: 'Verifica del backup…', + complete: 'Backup completato', + }, + successTitle: 'Backup completato', + successBody: 'La libreria del tuo Kobo è al sicuro.', + verifiedOk: 'Verificato: l’archivio è stato riletto e il checksum del database corrisponde.', + verifiedFail: + 'Verifica non riuscita: il file salvato potrebbe essere incompleto. Crea di nuovo il backup.', + encryptedNote: 'Questo backup è cifrato. Custodisci la password: non può essere recuperata.', + downloadNote: 'Il file è stato scaricato dal browser (di solito nella cartella Download).', + savedAs: 'Salvato come {filename}', + summary: { file: 'File', size: 'Dimensione', books: 'Libri', annotations: 'Evidenziazioni' }, + nextTitle: 'Mettilo al sicuro', + next: [ + 'Copialo su un disco esterno o in una cartella cloud', + 'Segui la regola 3-2-1: 3 copie, 2 supporti diversi, 1 fuori casa', + 'Crea un nuovo backup dopo molte letture o prima degli aggiornamenti del firmware', + ], + another: 'Backup di un altro Kobo', + openLibrary: 'Apri la libreria', + failedTitle: 'Backup non riuscito', + failedBody: 'Non è stato possibile completare il backup.', + failedHint: 'Con librerie molto grandi, chiudi le altre schede per liberare memoria e riprova.', + }, + + restore: { + title: 'Ripristina un backup', + stepLabels: ['File di backup', 'Kobo', 'Riepilogo', 'Ripristino'], + fileTitle: 'Scegli un file di backup', + fileSubtitle: 'Seleziona un backup di KoBup (kobo_backup_*.zip).', + drop: 'Trascina qui il backup', + or: 'oppure', + browse: 'Sfoglia i file', + reading: 'Lettura del backup…', + largeFile: + 'Backup di grandi dimensioni: il ripristino può richiedere un po’ di tempo. Tieni aperta questa scheda.', + passwordTitle: 'Questo backup è protetto da password', + passwordLabel: 'Password del backup', + unlock: 'Sblocca il backup', + deviceTitle: 'Scegli il Kobo su cui ripristinare', + deviceSubtitle: 'Collega il Kobo che deve ricevere il backup e seleziona la sua unità.', + deviceWarning: + 'Il ripristino modifica il database di questo Kobo. Prima viene salvata una copia del database attuale, così puoi annullare.', + selectDevice: 'Seleziona l’unità del Kobo', + reviewTitle: 'Controlla e scegli come ripristinare', + backupDetails: 'Backup', + targetDevice: 'Kobo di destinazione', + created: 'Creato', + device: 'Dispositivo', + firmware: 'Firmware', + books: 'Libri', + annotations: 'Evidenziazioni', + readingTime: 'Tempo di lettura', + finished: 'Finiti', + encrypted: 'Cifrato', + integrityOk: 'Integrità del backup verificata (il checksum del database corrisponde).', + integrityBad: + 'Attenzione: il database di questo backup non corrisponde al suo checksum. Il file potrebbe essere danneggiato.', + compatibility: { + title: 'Compatibilità', + ok: 'Nessun problema di compatibilità.', + model: + 'Il backup proviene da un {from}, questo è un {to}. I dati di lettura si trasferiscono senza problemi, le impostazioni forse no.', + firmware: 'Firmware diverso ({from} → {to}). Di norma funziona.', + 'schema-newer': + 'Il backup è stato creato con un software Kobo più recente (database versione {from}) di quello di questo Kobo ({to}). Aggiorna prima questo Kobo.', + 'old-backup': 'Questo backup ha {days} giorni.', + }, + modeTitle: 'Come ripristinare', + modes: { + merge: { + title: 'Unisci i dati di lettura', + badge: 'Consigliato', + body: 'Aggiunge avanzamento, evidenziazioni e raccolte dei tuoi libri al database di questo Kobo. Mantiene account, libri acquistati e impostazioni di questo Kobo. Ideale per passare a un Kobo nuovo o resettato.', + }, + full: { + title: 'Sostituisci tutto', + body: 'Rende il database di questo Kobo una copia esatta del backup, compresi l’accesso al vecchio account e la libreria dello store. Ideale per lo stesso Kobo dopo un ripristino di fabbrica.', + }, + }, + mergeWhat: 'Includi', + mergeProgress: 'Avanzamento di lettura', + mergeAnnotations: 'Evidenziazioni, note e segnalibri', + mergeCollections: 'Raccolte', + filesTitle: 'File', + restoreBooks: 'File dei libri ({count})', + restoreBooksHint: 'Ricopia i libri nelle loro cartelle originali.', + noBooksInBackup: 'Questo backup contiene solo il database: nessun file di libro verrà copiato.', + restoreSettings: 'Impostazioni, font e salvaschermi ({count})', + restoreSettingsHint: 'Ideale sullo stesso modello di Kobo.', + cleanFolders: 'Elimina prima le cartelle dei libri esistenti', + cleanFoldersHint: + 'Rimuove le cartelle dei libri presenti sul dispositivo prima della copia. I libri aggiunti dopo il backup andranno persi.', + confirm: 'Ho capito che il database di questo Kobo verrà modificato', + start: 'Avvia il ripristino', + progressTitle: 'Ripristino della libreria', + doNotDisconnect: 'Non scollegare il Kobo finché il ripristino non è terminato.', + stages: { + preparing: 'Preparazione del Kobo…', + snapshot: 'Salvataggio di una copia del database attuale…', + cleaning: 'Rimozione delle cartelle dei libri esistenti…', + merging: 'Unione dei dati di lettura…', + database: 'Scrittura del database…', + books: 'Copia dei libri ({current}/{total})…', + settings: 'Ripristino delle impostazioni…', + verifying: 'Verifica…', + complete: 'Ripristino completato', + }, + successTitle: 'Ripristino completato', + partialTitle: 'Ripristino completato con avvisi', + summary: { + books: 'Libri copiati', + merged: 'Libri aggiornati', + added: 'Libri aggiunti', + annotations: 'Evidenziazioni aggiunte', + collections: 'Raccolte aggiunte', + settings: 'File di impostazioni', + }, + failedFiles: { + one: '{count} file non è stato ripristinato', + other: '{count} file non sono stati ripristinati', + }, + verifyMismatch: + 'Il database ripristinato elenca {actual} libri, il backup ne aveva {expected}. Espelli e ricollega il Kobo per fargli ripetere la scansione.', + snapshotNote: + 'Il database precedente è stato salvato sul Kobo come .kobo/KoboReader.sqlite.before-restore.', + undo: 'Annulla questo ripristino', + undoConfirm: 'Vuoi rimettere il database che il Kobo aveva prima del ripristino?', + undone: 'Il database precedente è stato ripristinato.', + nextTitle: 'Per finire', + next: [ + 'Espelli il Kobo in modo sicuro dal computer', + 'Scollega il cavo USB e lascia che il Kobo elabori le modifiche', + 'Apri i tuoi libri e riprendi da dove eri rimasto', + ], + failedTitle: 'Ripristino non riuscito', + failedBody: 'Non è stato possibile completare il ripristino.', + failedDbWritten: + 'Il database potrebbe essere già stato modificato. Puoi annullare tornando alla copia salvata prima del ripristino.', + }, + + library: { + title: 'La tua libreria Kobo', + subtitle: 'Sfoglia libri, evidenziazioni e statistiche di lettura, direttamente dal tuo e-reader.', + connected: '{model} · firmware {firmware}', + backupCta: 'Fai il backup di questa libreria', + tabs: { books: 'Libri', annotations: 'Evidenziazioni', collections: 'Raccolte', stats: 'Statistiche' }, + search: 'Cerca libri o autori', + filter: 'Filtro', + sort: 'Ordina per', + filters: { all: 'Tutti i libri', reading: 'In lettura', finished: 'Finiti', unread: 'Da leggere' }, + sorts: { recent: 'Letti di recente', title: 'Titolo', author: 'Autore', progress: 'Avanzamento' }, + results: { one: '{count} libro', other: '{count} libri' }, + noResults: 'Nessun libro corrisponde alla ricerca.', + read: '{percent}% letto', + details: 'Dettagli del libro', + timeRead: 'Tempo di lettura', + format: 'Formato', + isbn: 'ISBN', + publisher: 'Editore', + series: 'Serie', + lastRead: 'Ultima lettura', + never: 'Mai', + noAnnotationsForBook: 'Ancora nessuna evidenziazione o nota per questo libro.', + exportTitle: 'Esporta le evidenziazioni', + exportBody: 'Porta le tue note nei tuoi strumenti preferiti.', + exportObsidian: 'Obsidian (ZIP Markdown)', + exportAnki: 'Anki (CSV)', + exportBook: 'Esporta in Markdown', + exportFailed: 'Esportazione non riuscita: {error}', + noAnnotations: 'Nessuna evidenziazione trovata. Evidenzia dei passaggi sul Kobo e compariranno qui.', + note: 'Nota', + copy: 'Copia', + copied: 'Copiato', + noCollections: 'Nessuna raccolta su questo Kobo.', + statsOverview: 'Panoramica di lettura', + progressBreakdown: 'Avanzamento', + topAuthors: 'Autori più presenti', + unreadLabel: 'Da leggere', + readingLabel: 'In corso', + finishedLabel: 'Finiti', + }, + + history: { + title: 'Cronologia backup', + subtitle: 'I backup creati in questo browser. L’elenco è salvato solo su questo computer.', + emptyTitle: 'Ancora nessun backup', + emptyBody: 'Crea il tuo primo backup per mettere al sicuro la libreria del Kobo.', + create: 'Crea un backup', + encrypted: 'Cifrato', + verified: 'Verificato', + verify: 'Verifica', + verifying: 'Verifica in corso…', + verifyOk: 'Il file è integro.', + verifyBad: 'Problemi rilevati: {details}', + restore: 'Ripristina', + remove: 'Rimuovi dalla cronologia', + removeTitle: 'Rimuovere questo backup dalla cronologia?', + removeBody: + 'Viene rimossa solo la voce della cronologia. Il file di backup sul disco non viene eliminato.', + notAvailable: + 'Impossibile aprire il file (spostato, rinominato o permesso negato). Usa «Ripristina» e selezionalo manualmente.', + bestTitle: 'Buone abitudini di backup', + best: [ + 'Fai backup regolari, soprattutto prima degli aggiornamenti del firmware', + 'Conserva copie in più posti (cloud + disco esterno)', + 'Ogni tanto controlla un backup verificandolo', + ], + }, + + errors: { + genericTitle: 'Qualcosa è andato storto', + genericBody: 'Si è verificato un errore imprevisto. I tuoi dati sono al sicuro.', + reload: 'Ricarica la pagina', + report: 'Segnala il problema su GitHub', + codes: { + INVALID_DEVICE: + 'Questa cartella non è un Kobo. Seleziona la radice dell’unità del Kobo (contiene una cartella .kobo).', + FS_NOT_SUPPORTED: + 'Questo browser non può accedere direttamente alle cartelle. Usa Chrome, Edge o Opera su computer.', + FS_PERMISSION_DENIED: 'Permesso negato. Consenti l’accesso al Kobo quando il browser lo chiede.', + FS_NOT_FOUND: 'Un file necessario non è stato trovato sul Kobo.', + FS_READ_ERROR: 'Impossibile leggere un file sul Kobo. Ricollega il Kobo e riprova.', + FS_WRITE_ERROR: + 'Scrittura sul Kobo non riuscita. Controlla che sia ancora collegato e che abbia spazio libero.', + DB_OPEN_FAILED: 'Impossibile aprire il database del Kobo.', + DB_QUERY_FAILED: 'Impossibile leggere il database del Kobo.', + DB_WRITE_ERROR: 'Impossibile preparare il database per il Kobo.', + BACKUP_FAILED: 'Impossibile creare il backup.', + RESTORE_INVALID_FILE: 'Questo file non è un backup di KoBup valido.', + RESTORE_CORRUPTED: 'Il file di backup è danneggiato o incompleto.', + RESTORE_PASSWORD_REQUIRED: 'Questo backup è cifrato: inserisci la password.', + RESTORE_WRONG_PASSWORD: 'Password errata. Riprova.', + RESTORE_DEVICE_BUSY: + 'Il database del Kobo ha modifiche non salvate. Espelli il Kobo, attendi qualche secondo, ricollegalo e riprova.', + RESTORE_FAILED: 'Impossibile completare il ripristino.', + SCAN_FAILED: 'Impossibile leggere il Kobo.', + }, + }, + + notFound: { + title: 'Pagina non trovata', + body: 'La pagina che cerchi non esiste o è stata spostata.', + home: 'Vai alla home page', + }, + + guide: { + title: 'Come fare il backup e il ripristino del Kobo', + intro: + 'KoBup funziona interamente nel browser. Questa guida ti accompagna nel backup, nel ripristino sullo stesso Kobo o su uno nuovo e nell’esportazione delle evidenziazioni.', + requirementsTitle: 'Cosa ti serve', + requirements: [ + 'Un computer con Chrome, Edge, Opera o Brave (backup e ripristino)', + 'Firefox e Safari vanno bene per i backup e la libreria, ma non per il ripristino', + 'Un cavo USB dati (alcuni cavi servono solo per la ricarica)', + ], + sections: [ + { + id: 'backup', + title: 'Crea un backup', + steps: [ + 'Collega il Kobo con il cavo USB, sbloccalo e tocca «Connetti».', + 'Apri «Backup» e seleziona l’unità del Kobo (di solito «KOBOeReader»).', + 'Controlla il riepilogo dei libri e delle evidenziazioni trovati da KoBup.', + 'Scegli cosa includere. Aggiungi una password se conserverai il backup nel cloud.', + 'Scegli dove salvare lo ZIP. KoBup lo scrive e poi lo verifica.', + ], + }, + { + id: 'new-kobo', + title: 'Trasferisci la libreria su un nuovo Kobo', + steps: [ + 'Configura il nuovo Kobo e accedi al tuo account Kobo.', + 'Collegalo, apri «Ripristino» e seleziona il file di backup.', + 'Seleziona l’unità del nuovo Kobo.', + 'Scegli «Unisci i dati di lettura»: avanzamento, evidenziazioni e raccolte vengono aggiunti mentre il nuovo Kobo mantiene il suo account.', + 'Avvia il ripristino, poi espelli il Kobo in modo sicuro.', + ], + }, + { + id: 'reset', + title: 'Ripristina lo stesso Kobo dopo un reset', + steps: [ + 'Collega il Kobo e apri «Ripristino».', + 'Seleziona il backup e l’unità del Kobo.', + 'Scegli «Sostituisci tutto» per una copia esatta del database salvato, oppure «Unisci» per mantenere l’account attuale.', + 'Se dopo qualcosa non va, usa «Annulla questo ripristino»: il database precedente è stato salvato sul Kobo.', + ], + }, + { + id: 'export', + title: 'Esporta le evidenziazioni in Obsidian o Anki', + steps: [ + 'Apri «Libreria» e collega il Kobo.', + 'Vai alla scheda «Evidenziazioni».', + 'Esporta tutto in Obsidian (una nota Markdown per libro) o in Anki (flashcard CSV), oppure esporta un singolo libro.', + ], + }, + ], + troubleshootingTitle: 'Risoluzione dei problemi', + troubleshooting: [ + { + q: 'Il Kobo non compare come unità', + a: 'Usa un cavo USB dati, sblocca il Kobo e tocca «Connetti». Su Windows cerca in Esplora file un’unità chiamata KOBOeReader.', + }, + { + q: '«Questa cartella non è un Kobo»', + a: 'Seleziona la radice dell’unità, non una sottocartella. La radice contiene una cartella nascosta «.kobo».', + }, + { + q: 'Il backup è lento', + a: 'La velocità dipende dalla memoria del Kobo e dalla connessione USB. Librerie di diversi gigabyte possono richiedere qualche minuto.', + }, + { + q: 'Dopo il ripristino i libri non hanno l’avanzamento', + a: 'I libri devono tornare nelle cartelle originali. Lascia che il Kobo finisca l’elaborazione dopo averlo espulso; se serve, ricollegalo una volta.', + }, + ], + }, + + faq: { + title: 'Domande frequenti', + intro: 'Tutto sul backup e sul ripristino del tuo e-reader Kobo con KoBup.', + categories: [ + { + title: 'Per iniziare', + items: [ + { + q: 'Che cos’è KoBup?', + a: 'KoBup è uno strumento web gratuito e open source che fa il backup e il ripristino degli e-reader Kobo direttamente nel browser. Legge in locale libri, evidenziazioni e avanzamento di lettura e li salva in un file ZIP sul tuo computer.', + }, + { + q: 'Quali browser sono supportati?', + a: 'Backup e libreria funzionano con tutti i browser moderni per computer. Il ripristino richiede la File System Access API per scrivere sul Kobo, disponibile in Chrome, Edge, Opera e Brave su computer.', + }, + { + q: 'Serve un account?', + a: 'No. Nessuna registrazione, nessun login. KoBup non ha un server che possa conservare i tuoi dati.', + }, + { + q: 'È gratuito?', + a: 'Sì, completamente. È un progetto personale open source con licenza MIT.', + }, + ], + }, + { + title: 'Backup e ripristino', + items: [ + { + q: 'Cosa contiene un backup?', + a: 'Il database del Kobo (avanzamento, evidenziazioni, note, segnalibri, raccolte) e, se vuoi, i file dei libri caricati, le impostazioni di lettura, i font personalizzati e i salvaschermi. I libri acquistati nello store Kobo non sono inclusi: puoi riscaricarli dal tuo account.', + }, + { + q: 'Posso trasferire la libreria su un nuovo Kobo?', + a: 'Sì. Durante il ripristino scegli «Unisci i dati di lettura»: avanzamento, evidenziazioni e raccolte vengono aggiunti al nuovo Kobo, che mantiene il proprio account e le proprie impostazioni.', + }, + { + q: 'Il ripristino cancella i libri attuali?', + a: 'No, a meno che tu non scelga esplicitamente di eliminare le cartelle dei libri esistenti. Prima di modificare il database, KoBup ne salva una copia sul Kobo, così puoi annullare il ripristino.', + }, + { + q: 'Come faccio a sapere che un backup è valido?', + a: 'Subito dopo averlo scritto, KoBup rilegge l’archivio, controlla che ci siano tutti i file e confronta il checksum del database. Puoi verificarlo di nuovo in seguito dalla pagina Cronologia.', + }, + { + q: 'Quanto tempo richiede?', + a: 'Un backup del solo database richiede pochi secondi. Un backup completo dipende dalle dimensioni della libreria e dalla velocità USB: qualche minuto per diversi gigabyte.', + }, + ], + }, + { + title: 'Privacy e sicurezza', + items: [ + { + q: 'I miei dati vengono inviati a un server?', + a: 'No. Tutto funziona nel browser con JavaScript e WebAssembly. La Content Security Policy del sito consente connessioni solo verso il sito stesso, quindi la pagina non può caricare la tua libreria altrove.', + }, + { + q: 'Perché dovrei cifrare un backup?', + a: 'Il database del Kobo contiene anche i token di accesso al tuo account Kobo. Se conservi i backup nel cloud, una password (AES-256) garantisce che nessun altro possa usarli.', + }, + { + q: 'KoBup usa analytics o cookie?', + a: 'Nessun cookie. Il sito usa Vercel Analytics senza cookie per contare le visite in forma anonima; non vede mai la tua libreria né i file di backup.', + }, + { + q: 'È affiliato con Rakuten Kobo?', + a: 'No. KoBup è uno strumento indipendente della community, non approvato da Rakuten Kobo Inc.', + }, + ], + }, + { + title: 'Domande tecniche', + items: [ + { + q: 'Funziona offline?', + a: 'Sì. Dopo la prima visita l’app viene salvata nella cache e può essere installata, quindi continua a funzionare anche senza connessione.', + }, + { + q: 'Posso usarlo da telefono o tablet?', + a: 'Non proprio: i browser mobili non possono accedere a un Kobo collegato via USB. Usa un computer fisso o portatile.', + }, + { + q: 'Perché mancano alcune copertine?', + a: 'Le copertine sono le miniature generate dal Kobo. I libri mai visualizzati nella libreria del Kobo potrebbero non averne ancora una; in quel caso KoBup mostra una copertina segnaposto.', + }, + ], + }, + ], + moreTitle: 'Hai altre domande?', + moreBody: 'Leggi la guida passo passo o apri una segnalazione su GitHub.', + moreGuide: 'Leggi la guida', + }, + + privacy: { + title: 'Informativa privacy', + updated: 'Ultimo aggiornamento: {date}', + intro: + 'KoBup è uno strumento gratuito, non commerciale e open source, progettato perché la tua libreria Kobo non lasci mai il tuo computer. Questa pagina spiega esattamente cosa viene elaborato e dove.', + sections: [ + { + title: 'I dati del Kobo restano sul tuo computer', + paragraphs: [ + 'Quando crei o ripristini un backup, o apri la libreria, KoBup legge il database e i file del Kobo direttamente nel browser. Nulla viene caricato: non esiste un server di KoBup che riceva dati.', + 'Lo garantisce una Content Security Policy che consente alla pagina di connettersi solo al sito stesso.', + ], + }, + { + title: 'Dati salvati nel browser', + paragraphs: [ + 'KoBup salva in locale pochi dati, che puoi eliminare in qualsiasi momento cancellando i dati del sito:', + ], + items: [ + 'La cronologia dei backup (data, dimensione, numero di libri) nel local storage', + 'I riferimenti ai file di backup salvati in IndexedDB, per verificarli o ripristinarli in seguito (nessun contenuto dei file)', + 'Se hai chiuso l’avviso sugli analytics', + ], + }, + { + title: 'Visite anonime (Vercel Analytics)', + paragraphs: [ + 'Il sito kobup.org usa Vercel Analytics per contare le visite. Non imposta cookie e non ti traccia tra siti diversi. Registra l’indirizzo della pagina, il referrer, il paese approssimativo, il tipo di browser e di dispositivo. Non riceve mai la tua libreria né i tuoi backup.', + 'Le copie di KoBup ospitate in autonomia possono funzionare senza alcun analytics.', + ], + }, + { + title: 'Nessuna richiesta a terze parti', + paragraphs: [ + 'I font e tutte le altre risorse sono serviti da kobup.org. Il sito non carica nulla da Google o da altre terze parti.', + ], + }, + { + title: 'Base giuridica e diritti (GDPR)', + paragraphs: [ + 'L’elaborazione locale è necessaria per fornire lo strumento che usi. Gli analytics aggregati e senza cookie si basano sul legittimo interesse (art. 6(1)(f) GDPR) e puoi bloccarli con qualsiasi content blocker.', + 'Poiché KoBup non conserva dati personali che ti riguardano, non c’è nulla da consultare, correggere o cancellare da parte nostra. Per domande puoi aprire una segnalazione su GitHub.', + ], + }, + { + title: 'Modifiche', + paragraphs: [ + 'Se questa informativa cambia, la data in alto viene aggiornata. Lo storico completo è pubblico su GitHub.', + ], + }, + ], + }, +}; + +export default it; diff --git a/src/main.jsx b/src/main.jsx deleted file mode 100644 index 99c2e15..0000000 --- a/src/main.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App.jsx'; -import { ErrorBoundary } from './components/ErrorBoundary.jsx'; -import './styles/fonts.ts'; -import './styles/globals.css'; - -ReactDOM.createRoot(document.getElementById('root')).render( - <React.StrictMode> - <ErrorBoundary> - <App /> - </ErrorBoundary> - </React.StrictMode>, -); diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..ed8614a --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from 'react'; +import { createRoot, hydrateRoot } from 'react-dom/client'; +import App from './App.tsx'; +import './styles/fonts.ts'; +import './styles/globals.css'; + +const container = document.getElementById('root')!; +const app = ( + <StrictMode> + <App path={window.location.pathname} /> + </StrictMode> +); + +// Pages are prerendered at build time: hydrate them, render from scratch otherwise (dev). +if (container.hasChildNodes()) hydrateRoot(container, app); +else createRoot(container).render(app); diff --git a/src/pages/Backup.jsx b/src/pages/Backup.jsx deleted file mode 100644 index 047e2be..0000000 --- a/src/pages/Backup.jsx +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Backup page - full backup wizard - */ - -import { BackupWizard } from '../components/backup/BackupWizard.jsx'; - -export function Backup({ onComplete }) { - return <BackupWizard onComplete={onComplete} />; -} diff --git a/src/pages/Backup.tsx b/src/pages/Backup.tsx new file mode 100644 index 0000000..556db22 --- /dev/null +++ b/src/pages/Backup.tsx @@ -0,0 +1,419 @@ +import { useId, useState } from 'react'; +import { useI18n } from '../i18n/core.ts'; +import { Link } from '../router/Router.tsx'; +import { useDevice } from '../hooks/useDevice.ts'; +import { useBackup } from '../hooks/useBackup.ts'; +import { useFeatureDetection } from '../hooks/useFeatureDetection.ts'; +import { newBackupId, useKoboStore } from '../stores/koboStore.ts'; +import { saveBackupHandle } from '../utils/handleStore.ts'; +import { estimateBackupSize, generateBackupFilename } from '../utils/backupInfo.ts'; +import { Container } from '../components/layout/Container.tsx'; +import { PageHeader } from '../components/common/PageHeader.tsx'; +import { Stepper } from '../components/common/Stepper.tsx'; +import { Card } from '../components/common/Card.tsx'; +import { Button } from '../components/common/Button.tsx'; +import { Checkbox } from '../components/common/Checkbox.tsx'; +import { Icon, type IconType } from '../components/common/Icon.tsx'; +import { Alert, ErrorAlert } from '../components/common/Alert.tsx'; +import { CircularProgress } from '../components/common/CircularProgress.tsx'; +import { ConnectDevice } from '../components/device/ConnectDevice.tsx'; +import type { BackupOptions, ScanResult } from '../types/kobo.ts'; + +type Step = 'overview' | 'options' | 'run'; + +const MIN_PASSWORD = 8; + +export function Backup() { + const { m } = useI18n(); + const device = useDevice(); + const [step, setStep] = useState<Step>('overview'); + const scan = device.scan; + + const stepIndex = !scan ? 0 : step === 'overview' ? 1 : step === 'options' ? 2 : 3; + + return ( + <Container className="max-w-5xl py-12"> + <PageHeader title={m.backup.title} /> + <Stepper steps={m.backup.stepLabels} current={stepIndex} /> + {!scan ? ( + <ConnectDevice device={device} /> + ) : step === 'overview' ? ( + <Overview scan={scan} onContinue={() => setStep('options')} onDisconnect={device.disconnect} /> + ) : ( + <BackupFlow + scan={scan} + step={step} + setStep={setStep} + onAnother={() => { + device.disconnect(); + setStep('overview'); + }} + /> + )} + </Container> + ); +} + +function Stat({ icon, label, value }: { icon: IconType; label: string; value: string | number }) { + return ( + <Card className="text-center"> + <Icon type={icon} size={28} className="mx-auto mb-2 text-kobo-accent-ink" /> + <p className="font-display text-2xl text-kobo-dark">{value}</p> + <p className="text-sm text-kobo-gray">{label}</p> + </Card> + ); +} + +function Overview({ + scan, + onContinue, + onDisconnect, +}: { + scan: ScanResult; + onContinue: () => void; + onDisconnect: () => void; +}) { + const { m, fmt, plural, formatBytes, formatDuration, formatNumber } = useI18n(); + const s = m.backup.stats; + + return ( + <div className="space-y-6"> + <div className="text-center"> + <h2 className="mb-1 text-3xl text-kobo-dark">{m.backup.overviewTitle}</h2> + <p className="text-kobo-gray">{fmt(m.backup.overviewSubtitle, { model: scan.deviceInfo.model })}</p> + </div> + + {scan.warnings.includes('wal-pending') && <Alert tone="warning">{m.warnings.walPending}</Alert>} + + <div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> + <Stat icon="book" label={s.books} value={formatNumber(scan.books.length)} /> + <Stat icon="note" label={s.annotations} value={formatNumber(scan.annotations.length)} /> + <Stat icon="storage" label={s.size} value={formatBytes(estimateBackupSize(scan))} /> + <Stat icon="chart" label={s.finished} value={formatNumber(scan.stats.booksFinished)} /> + </div> + + <Card> + <dl className="grid grid-cols-1 gap-4 sm:grid-cols-3"> + {[ + [s.started, formatNumber(scan.stats.booksStarted)], + [s.reading, formatNumber(scan.stats.currentlyReading)], + [s.timeRead, formatDuration(scan.stats.totalMinutesRead)], + ].map(([label, value]) => ( + <div key={label}> + <dt className="text-sm text-kobo-gray">{label}</dt> + <dd className="font-display text-2xl text-kobo-dark">{value}</dd> + </div> + ))} + </dl> + </Card> + + {scan.books.length > 0 && ( + <Card> + <h3 className="mb-4 text-xl text-kobo-dark">{m.backup.recent}</h3> + <ul className="space-y-2"> + {scan.books.slice(0, 5).map((book) => ( + <li key={book.ContentID} className="flex items-center gap-4 rounded-lg bg-kobo-cream-dark p-3"> + <div className="min-w-0 flex-1"> + <p className="truncate font-semibold text-kobo-dark">{book.Title}</p> + <p className="truncate text-sm text-kobo-gray">{book.Author}</p> + </div> + <span className="text-sm font-semibold text-kobo-accent-ink"> + {fmt(m.library.read, { percent: book.Progress })} + </span> + </li> + ))} + </ul> + {scan.books.length > 5 && ( + <p className="mt-3 text-center text-sm text-kobo-gray"> + {plural(m.backup.more, scan.books.length - 5)} + </p> + )} + </Card> + )} + + <div className="flex flex-col items-center justify-center gap-3 sm:flex-row"> + <Button size="lg" onClick={onContinue}> + {m.backup.toOptions} + </Button> + <Button size="lg" variant="ghost" onClick={onDisconnect}> + {m.connect.disconnect} + </Button> + </div> + </div> + ); +} + +function BackupFlow({ + scan, + step, + setStep, + onAnother, +}: { + scan: ScanResult; + step: Step; + setStep: (s: Step) => void; + onAnother: () => void; +}) { + const { m, fmt, formatBytes } = useI18n(); + const features = useFeatureDetection(); + const addBackup = useKoboStore((s) => s.addBackup); + const backup = useBackup(); + const pwId = useId(); + + const [options, setOptions] = useState<Omit<BackupOptions, 'includeProgress'>>({ + includeBooks: true, + includeAnnotations: true, + includeSettings: scan.extraFiles.length > 0, + }); + const [encrypt, setEncrypt] = useState(false); + const [password, setPassword] = useState(''); + const [confirm, setConfirm] = useState(''); + const [touched, setTouched] = useState(false); + + const passwordError = !encrypt + ? null + : password.length < MIN_PASSWORD + ? m.backup.passwordTooShort + : password !== confirm + ? m.backup.passwordMismatch + : null; + + const start = async () => { + setTouched(true); + if (passwordError) return; + const suggestedFilename = generateBackupFilename(); + + // showSaveFilePicker must run inside the click (user gesture), before other awaits. + let handle: FileSystemFileHandle | null = null; + if (features.saveFilePicker && 'showSaveFilePicker' in window) { + try { + handle = await window.showSaveFilePicker({ + suggestedName: suggestedFilename, + startIn: 'downloads', + types: [{ description: 'KoBup backup', accept: { 'application/zip': ['.zip'] } }], + }); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + console.warn('[BACKUP] showSaveFilePicker failed, using in-memory download:', err); + } + } + + setStep('run'); + const result = await backup.create(scan, { + ...options, + includeProgress: true, + password: encrypt ? password : undefined, + writableFileHandle: handle, + suggestedFilename: handle?.name ?? suggestedFilename, + }); + if (!result) return; + + const id = newBackupId(); + const hasHandle = handle ? await saveBackupHandle(id, handle) : false; + addBackup({ + id, + filename: result.filename, + created: new Date().toISOString(), + size: result.size, + deviceModel: scan.deviceInfo.model, + bookCount: scan.books.length, + annotationCount: scan.annotations.length, + encrypted: encrypt, + hasHandle, + verified: result.verification?.ok ?? undefined, + }); + }; + + if (step === 'options') { + const size = estimateBackupSize(scan, options); + return ( + <Card className="mx-auto max-w-2xl"> + <h2 className="mb-2 text-center text-3xl text-kobo-dark">{m.backup.optionsTitle}</h2> + <p className="mb-8 text-center text-kobo-gray">{m.backup.optionsSubtitle}</p> + + <fieldset className="mb-8 space-y-4"> + <legend className="sr-only">{m.backup.optionsTitle}</legend> + <Checkbox + checked={options.includeBooks} + onChange={(v) => setOptions({ ...options, includeBooks: v })} + label={fmt(m.backup.includeBooks, { count: scan.bookFiles.length })} + sublabel={m.backup.includeBooksHint} + /> + <Checkbox + checked={options.includeAnnotations} + onChange={(v) => setOptions({ ...options, includeAnnotations: v })} + label={m.backup.includeAnnotations} + sublabel={m.backup.includeAnnotationsHint} + disabled={scan.annotations.length === 0} + /> + <Checkbox + checked={options.includeSettings} + onChange={(v) => setOptions({ ...options, includeSettings: v })} + label={fmt(m.backup.includeSettings, { count: scan.extraFiles.length })} + sublabel={m.backup.includeSettingsHint} + disabled={scan.extraFiles.length === 0} + /> + </fieldset> + + <fieldset className="mb-8 rounded-lg border border-kobo-cream-dark p-4"> + <legend className="sr-only">{m.backup.encryptTitle}</legend> + <Checkbox + checked={encrypt} + onChange={setEncrypt} + label={m.backup.encryptTitle} + sublabel={m.backup.encryptHint} + /> + {encrypt && ( + <div className="mt-4 grid gap-4 sm:grid-cols-2"> + <label className="block text-sm font-medium text-kobo-dark"> + {m.backup.password} + <input + id={pwId} + type="password" + autoComplete="new-password" + value={password} + onChange={(e) => setPassword(e.target.value)} + aria-invalid={touched && !!passwordError} + aria-describedby={`${pwId}-error`} + className="mt-1 w-full rounded-lg border border-kobo-gray-light px-3 py-2 focus:border-kobo-accent focus:outline-hidden focus:ring-2 focus:ring-kobo-accent/50" + /> + </label> + <label className="block text-sm font-medium text-kobo-dark"> + {m.backup.passwordConfirm} + <input + type="password" + autoComplete="new-password" + value={confirm} + onChange={(e) => setConfirm(e.target.value)} + aria-invalid={touched && !!passwordError} + aria-describedby={`${pwId}-error`} + className="mt-1 w-full rounded-lg border border-kobo-gray-light px-3 py-2 focus:border-kobo-accent focus:outline-hidden focus:ring-2 focus:ring-kobo-accent/50" + /> + </label> + <p + id={`${pwId}-error`} + className="text-sm text-kobo-error sm:col-span-2" + role={touched && passwordError ? 'alert' : undefined} + > + {touched ? passwordError : null} + </p> + </div> + )} + </fieldset> + + <Alert tone="info" className="mb-8"> + <p className="font-semibold">{fmt(m.backup.estimated, { size: formatBytes(size) })}</p> + <p className="text-kobo-gray">{m.backup.diskSpace}</p> + </Alert> + + <div className="flex flex-col gap-3 sm:flex-row-reverse"> + <Button size="lg" onClick={start} className="flex-1"> + <Icon type="download" /> + {m.backup.start} + </Button> + <Button size="lg" variant="secondary" onClick={() => setStep('overview')}> + {m.common.back} + </Button> + </div> + {features.saveFilePicker && ( + <p className="mt-3 text-center text-sm text-kobo-gray">{m.backup.saveDialogHint}</p> + )} + </Card> + ); + } + + if (backup.error) { + return ( + <Card className="mx-auto max-w-2xl text-center"> + <h2 className="mb-2 text-2xl text-kobo-dark">{m.backup.failedTitle}</h2> + <p className="mb-4 text-kobo-gray">{m.backup.failedBody}</p> + <ErrorAlert error={backup.error} /> + <p className="my-6 text-sm text-kobo-gray">{m.backup.failedHint}</p> + <Button + onClick={() => { + backup.reset(); + setStep('options'); + }} + > + {m.common.retry} + </Button> + </Card> + ); + } + + if (backup.result) { + const r = backup.result; + return ( + <Card className="mx-auto max-w-3xl text-center"> + <div className="mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full bg-kobo-success/20"> + <Icon type="check" size={40} className="text-kobo-dark" /> + </div> + <h2 className="mb-2 text-4xl text-kobo-dark">{m.backup.successTitle}</h2> + <p className="mb-6 text-lg text-kobo-gray">{m.backup.successBody}</p> + + <div className="mb-6 space-y-3"> + {r.verification && ( + <Alert tone={r.verification.ok ? 'success' : 'error'} live> + {r.verification.ok ? m.backup.verifiedOk : m.backup.verifiedFail} + </Alert> + )} + {r.metadata.options?.encrypted && <Alert tone="warning">{m.backup.encryptedNote}</Alert>} + <Alert tone="info"> + {r.streamed ? fmt(m.backup.savedAs, { filename: r.filename }) : m.backup.downloadNote} + </Alert> + </div> + + <dl className="mb-8 grid grid-cols-2 gap-4 sm:grid-cols-4"> + {[ + [m.backup.summary.file, r.filename], + [m.backup.summary.size, formatBytes(r.size)], + [m.backup.summary.books, String(r.metadata.statistics.totalBooks)], + [m.backup.summary.annotations, String(r.metadata.statistics.totalAnnotations)], + ].map(([label, value]) => ( + <div key={label} className="rounded-lg border-2 border-kobo-cream-dark p-3"> + <dt className="text-xs text-kobo-gray">{label}</dt> + <dd className="truncate text-kobo-dark" title={value}> + {value} + </dd> + </div> + ))} + </dl> + + <div className="mb-8 rounded-lg bg-kobo-cream-dark p-6 text-left"> + <h3 className="mb-3 text-xl text-kobo-dark">{m.backup.nextTitle}</h3> + <ul className="list-disc space-y-1 pl-5 text-kobo-gray"> + {m.backup.next.map((n) => ( + <li key={n}>{n}</li> + ))} + </ul> + </div> + + <div className="flex flex-col justify-center gap-3 sm:flex-row"> + <Link + to="library" + className="rounded-lg bg-kobo-accent px-6 py-3 text-lg font-semibold text-kobo-dark hover:bg-kobo-accent-dark focus-visible-ring" + > + {m.backup.openLibrary} + </Link> + <Button size="lg" variant="secondary" onClick={onAnother}> + {m.backup.another} + </Button> + </div> + </Card> + ); + } + + const p = backup.progress; + return ( + <Card className="mx-auto max-w-2xl text-center"> + <h2 className="mb-8 text-3xl text-kobo-dark">{m.backup.progressTitle}</h2> + <div className="mb-6 flex justify-center"> + <CircularProgress percent={p.percent} label={m.backup.progressTitle} /> + </div> + <p className="mb-6 text-lg text-kobo-dark" role="status" aria-live="polite"> + {fmt(m.backup.stages[p.stage], { current: p.filesProcessed, total: p.totalFiles })} + </p> + <Alert tone="warning">{m.backup.keepOpen}</Alert> + </Card> + ); +} diff --git a/src/pages/FAQ.jsx b/src/pages/FAQ.jsx deleted file mode 100644 index eba8bf9..0000000 --- a/src/pages/FAQ.jsx +++ /dev/null @@ -1,244 +0,0 @@ -/** - * FAQ page - SEO optimized with structured data for search engines - */ - -import { useState, useEffect } from 'react'; -import { Button } from '../components/common/Button.tsx'; - -const faqs = [ - { - category: 'Getting Started', - questions: [ - { - question: 'What is Kobo Backup Manager?', - answer: - 'Kobo Backup Manager is a free, open-source web application that allows you to backup and restore your Kobo e-reader library entirely within your browser. It processes all data locally without sending anything to external servers, ensuring complete privacy.', - }, - { - question: 'Which browsers are supported?', - answer: - 'Kobo Backup Manager requires Chrome 86 or later, Microsoft Edge 86 or later, or any Chromium-based browser that supports the File System Access API. Unfortunately, Safari and Firefox do not currently support the required APIs.', - }, - { - question: 'Do I need to create an account?', - answer: - 'No account is required. Kobo Backup Manager works entirely in your browser without any registration, login, or personal information collection.', - }, - { - question: 'Is Kobo Backup Manager free?', - answer: - 'Yes, Kobo Backup Manager is completely free and open source. The source code is available on GitHub for anyone to review, use, or contribute to.', - }, - ], - }, - { - category: 'Backup & Restore', - questions: [ - { - question: 'What data does a backup include?', - answer: - 'A backup includes your Kobo library database (books, reading progress, highlights, annotations, bookmarks, collections, shelves) and optionally the book files themselves. You can choose which data to include when creating a backup.', - }, - { - question: 'How long does it take to create a backup?', - answer: - 'Backup time depends on your library size. Small libraries (under 100 books) typically take 30-60 seconds, while larger libraries (500+ books) may take 3-5 minutes. All processing happens in your browser, so performance varies based on your device.', - }, - { - question: 'Where are my backups stored?', - answer: - 'Backups are saved as ZIP files to a location of your choice on your computer. You have complete control over where backups are stored, and they can be moved or copied like any other file.', - }, - { - question: 'Can I restore a backup to a different Kobo device?', - answer: - 'Yes, you can restore backups to any Kobo e-reader. This is useful when upgrading to a new device or recovering from a device reset. Note that some device-specific settings may not transfer.', - }, - { - question: 'Will restoring delete my current library?', - answer: - 'Restoring a backup will replace the database on your Kobo device, which effectively replaces your current library with the backed-up version. Always create a backup of your current library before restoring an older one.', - }, - ], - }, - { - category: 'Privacy & Security', - questions: [ - { - question: 'Is my data sent to any servers?', - answer: - 'No. All processing happens entirely within your browser using JavaScript and WebAssembly. No data is uploaded to any server or cloud service. Your library data never leaves your device.', - }, - { - question: 'Does Kobo Backup Manager use analytics?', - answer: - 'Yes, we use Google Analytics 4 (GA4) and ContentSquare to understand how users interact with the app and improve the service. These tools collect anonymous usage statistics but do not access your Kobo library data or backup files. See our Privacy Policy for full details.', - }, - { - question: 'Can I verify the code is safe?', - answer: - 'Yes! Kobo Backup Manager is fully open source. You can review the complete source code on GitHub to verify what it does and ensure it meets your security standards.', - }, - { - question: 'Is this affiliated with Rakuten Kobo?', - answer: - 'No, Kobo Backup Manager is an independent, community-created tool and is not affiliated with, endorsed by, or supported by Rakuten Kobo Inc.', - }, - ], - }, - { - category: 'Technical Questions', - questions: [ - { - question: "Why can't I use Firefox or Safari?", - answer: - 'Kobo Backup Manager requires the File System Access API to directly read from and write to your Kobo device. This API is currently only available in Chromium-based browsers (Chrome, Edge, Opera, Brave). Firefox and Safari have not yet implemented this standard.', - }, - { - question: 'What is the File System Access API?', - answer: - 'The File System Access API is a modern web standard that allows web applications to read and write files with user permission. It enables Kobo Backup Manager to access your Kobo device without requiring browser extensions or native applications.', - }, - { - question: 'Does this work offline?', - answer: - "After the initial load, Kobo Backup Manager can work offline. The app is cached in your browser, so you can create and restore backups without an internet connection once you've visited the site.", - }, - { - question: 'Can I use this on mobile devices?', - answer: - 'Currently, mobile browsers do not support the File System Access API needed to read from Kobo devices. Kobo Backup Manager is designed for desktop use with Chrome, Edge, or other Chromium-based browsers.', - }, - ], - }, - { - category: 'Troubleshooting', - questions: [ - { - question: "I can't find the KoboReader.sqlite file", - answer: - "The KoboReader.sqlite file is located in the .kobo folder inside your Kobo device. Make sure your Kobo is connected via USB and recognized as a storage device. On Windows, look for the .kobo folder in your Kobo's root directory. Note that folders starting with a dot may be hidden by default.", - }, - { - question: 'The backup process failed or got stuck', - answer: - 'Try disconnecting and reconnecting your Kobo device, then close and reopen your browser. If the issue persists, your Kobo database may be corrupted - try connecting your Kobo to Kobo Desktop software first to let it repair the database.', - }, - { - question: "My device isn't recognized", - answer: - "Ensure your Kobo is connected via USB and is in USB storage mode (not charging only). Some USB cables are charge-only and won't work - use a data-capable USB cable. Also verify your browser has permission to access files.", - }, - { - question: 'The app shows "Browser not supported"', - answer: - "This means you're using a browser that doesn't support the File System Access API. Please switch to Chrome 86+, Edge 86+, or another Chromium-based browser. Update your browser to the latest version if the message persists.", - }, - ], - }, -]; - -export function FAQ({ onNavigate }) { - const [openIndex, setOpenIndex] = useState(null); - - // Add structured data for SEO - useEffect(() => { - const script = document.createElement('script'); - script.type = 'application/ld+json'; - script.text = JSON.stringify({ - '@context': 'https://schema.org', - '@type': 'FAQPage', - mainEntity: faqs.flatMap((category) => - category.questions.map((q) => ({ - '@type': 'Question', - name: q.question, - acceptedAnswer: { - '@type': 'Answer', - text: q.answer, - }, - })), - ), - }); - document.head.appendChild(script); - - return () => { - document.head.removeChild(script); - }; - }, []); - - const toggleQuestion = (categoryIndex, questionIndex) => { - const index = `${categoryIndex}-${questionIndex}`; - setOpenIndex(openIndex === index ? null : index); - }; - - return ( - <div className="max-w-4xl mx-auto px-4 py-12"> - <Button variant="ghost" onClick={() => onNavigate('home')} className="mb-8"> - ← Back to Home - </Button> - - <header className="mb-12"> - <h1 className="text-4xl font-display font-bold text-kobo-dark mb-4">Frequently Asked Questions</h1> - <p className="text-lg text-kobo-gray"> - Everything you need to know about backing up and restoring your Kobo e-reader library. - </p> - </header> - - {faqs.map((category, categoryIndex) => ( - <section key={categoryIndex} className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-6 border-b-2 border-kobo-accent pb-2"> - {category.category} - </h2> - <div className="space-y-4"> - {category.questions.map((faq, questionIndex) => { - const isOpen = openIndex === `${categoryIndex}-${questionIndex}`; - return ( - <div - key={questionIndex} - className="bg-white rounded-xl shadow-xs border border-kobo-gray-light overflow-hidden" - > - <button - onClick={() => toggleQuestion(categoryIndex, questionIndex)} - className="w-full px-6 py-4 text-left flex items-center justify-between hover:bg-kobo-cream/50 transition-colors" - aria-expanded={isOpen} - > - <h3 className="text-lg font-display font-bold text-kobo-dark pr-4">{faq.question}</h3> - <svg - className={`w-6 h-6 text-kobo-accent shrink-0 transition-transform ${ - isOpen ? 'rotate-180' : '' - }`} - fill="none" - stroke="currentColor" - viewBox="0 0 24 24" - > - <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> - </svg> - </button> - {isOpen && ( - <div className="px-6 pb-4"> - <p className="text-kobo-gray leading-relaxed">{faq.answer}</p> - </div> - )} - </div> - ); - })} - </div> - </section> - ))} - - <section className="mt-16 bg-kobo-accent/10 rounded-xl p-8 text-center"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Still have questions?</h2> - <p className="text-kobo-gray mb-6"> - Check out our comprehensive User Guide for detailed instructions, or visit our GitHub repository to - report issues and contribute. - </p> - <div className="flex gap-4 justify-center"> - <Button onClick={() => onNavigate('guide')}>Read User Guide</Button> - <Button variant="secondary" onClick={() => window.open('https://github.com', '_blank')}> - Visit GitHub - </Button> - </div> - </section> - </div> - ); -} diff --git a/src/pages/Faq.tsx b/src/pages/Faq.tsx new file mode 100644 index 0000000..217060d --- /dev/null +++ b/src/pages/Faq.tsx @@ -0,0 +1,63 @@ +import { useI18n } from '../i18n/core.ts'; +import { Link } from '../router/Router.tsx'; +import { REPO_URL } from '../seo/meta.ts'; +import { Container } from '../components/layout/Container.tsx'; +import { Icon } from '../components/common/Icon.tsx'; + +export function Faq() { + const { m } = useI18n(); + const f = m.faq; + + return ( + <Container size="sm" className="py-12"> + <h1 className="mb-4 text-4xl text-kobo-dark sm:text-5xl">{f.title}</h1> + <p className="mb-12 text-lg text-kobo-gray">{f.intro}</p> + + {f.categories.map((category) => ( + <section key={category.title} className="mb-12" aria-label={category.title}> + <h2 className="mb-6 border-b-2 border-kobo-accent pb-2 text-2xl text-kobo-dark"> + {category.title} + </h2> + <div className="space-y-3"> + {category.items.map((item) => ( + <details + key={item.q} + className="group rounded-xl border border-kobo-cream-dark bg-white shadow-xs" + > + <summary className="flex cursor-pointer list-none items-center justify-between gap-4 px-6 py-4 hover:bg-kobo-cream/50"> + <h3 className="font-display text-lg font-bold text-kobo-dark">{item.q}</h3> + <Icon + type="chevronDown" + className="shrink-0 text-kobo-accent-ink transition-transform group-open:rotate-180" + /> + </summary> + <p className="px-6 pb-5 leading-relaxed text-kobo-gray">{item.a}</p> + </details> + ))} + </div> + </section> + ))} + + <section className="rounded-xl bg-kobo-accent/10 p-8 text-center"> + <h2 className="mb-3 text-2xl text-kobo-dark">{f.moreTitle}</h2> + <p className="mb-6 text-kobo-gray">{f.moreBody}</p> + <div className="flex flex-col justify-center gap-3 sm:flex-row"> + <Link + to="guide" + className="rounded-lg bg-kobo-accent px-5 py-2.5 font-semibold text-kobo-dark hover:bg-kobo-accent-dark focus-visible-ring" + > + {f.moreGuide} + </Link> + <a + href={`${REPO_URL}/issues`} + target="_blank" + rel="noopener noreferrer" + className="rounded-lg border-2 border-kobo-gray-light bg-white px-5 py-2.5 font-semibold text-kobo-dark hover:bg-kobo-cream focus-visible-ring" + > + GitHub <span className="sr-only">{m.common.opensInNewTab}</span> + </a> + </div> + </section> + </Container> + ); +} diff --git a/src/pages/Guide.tsx b/src/pages/Guide.tsx new file mode 100644 index 0000000..72bcdca --- /dev/null +++ b/src/pages/Guide.tsx @@ -0,0 +1,89 @@ +import { useI18n } from '../i18n/core.ts'; +import { Link } from '../router/Router.tsx'; +import { Container } from '../components/layout/Container.tsx'; +import { Alert } from '../components/common/Alert.tsx'; + +export function Guide() { + const { m } = useI18n(); + const g = m.guide; + + return ( + <Container size="sm" className="py-12"> + <article> + <h1 className="mb-4 text-4xl text-kobo-dark sm:text-5xl">{g.title}</h1> + <p className="mb-8 text-lg text-kobo-gray">{g.intro}</p> + + <nav aria-label={g.title} className="mb-10 rounded-xl bg-kobo-cream-dark/50 p-5"> + <ul className="grid gap-2 sm:grid-cols-2"> + {g.sections.map((s) => ( + <li key={s.id}> + <a href={`#${s.id}`} className="text-kobo-accent-ink underline"> + {s.title} + </a> + </li> + ))} + </ul> + </nav> + + <section className="mb-12" aria-labelledby="requirements"> + <h2 id="requirements" className="mb-4 text-2xl text-kobo-dark"> + {g.requirementsTitle} + </h2> + <Alert tone="info"> + <ul className="list-disc space-y-1 pl-5"> + {g.requirements.map((r) => ( + <li key={r}>{r}</li> + ))} + </ul> + </Alert> + </section> + + {g.sections.map((section) => ( + <section + key={section.id} + id={section.id} + className="mb-12 scroll-mt-24" + aria-labelledby={`${section.id}-title`} + > + <h2 id={`${section.id}-title`} className="mb-4 text-2xl text-kobo-dark"> + {section.title} + </h2> + <ol className="space-y-3"> + {section.steps.map((step, i) => ( + <li key={step} className="flex gap-4"> + <span + className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-kobo-accent font-bold text-kobo-dark" + aria-hidden="true" + > + {i + 1} + </span> + <p className="pt-1 text-kobo-dark">{step}</p> + </li> + ))} + </ol> + </section> + ))} + + <section className="mb-12" aria-labelledby="troubleshooting"> + <h2 id="troubleshooting" className="mb-4 text-2xl text-kobo-dark"> + {g.troubleshootingTitle} + </h2> + <dl className="space-y-4"> + {g.troubleshooting.map((item) => ( + <div key={item.q} className="rounded-xl border border-kobo-cream-dark bg-white p-5"> + <dt className="mb-1 font-display text-lg font-bold text-kobo-dark">{item.q}</dt> + <dd className="text-kobo-gray">{item.a}</dd> + </div> + ))} + </dl> + </section> + + <p className="text-center"> + <Link to="faq" className="font-semibold text-kobo-accent-ink underline"> + {m.home.faqMore} + </Link> + </p> + </article> + </Container> + ); +} diff --git a/src/pages/History.jsx b/src/pages/History.jsx deleted file mode 100644 index 0284360..0000000 --- a/src/pages/History.jsx +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Backup history page - */ - -import { useState } from 'react'; -import { useKoboStore } from '../stores/koboStore.ts'; -import { Container } from '../components/layout/Container.tsx'; -import { Card } from '../components/common/Card.tsx'; -import { Button } from '../components/common/Button.tsx'; -import { Icon } from '../components/common/Icon.tsx'; -import { Modal } from '../components/common/Modal.tsx'; -import { formatBytes, formatDate } from '../utils/formatters.ts'; - -export function History({ onNavigate }) { - const backups = useKoboStore((state) => state.backups); - const removeBackup = useKoboStore((state) => state.removeBackup); - const [deleteModal, setDeleteModal] = useState({ isOpen: false, backup: null }); - - const handleDelete = (backup) => { - setDeleteModal({ isOpen: true, backup }); - }; - - const confirmDelete = () => { - if (deleteModal.backup) { - removeBackup(deleteModal.backup.id); - } - setDeleteModal({ isOpen: false, backup: null }); - }; - - const handleRestore = () => { - // Navigate to restore page - // In a real implementation, you might want to pre-load the backup file - if (onNavigate) { - onNavigate('restore'); - } - }; - - if (backups.length === 0) { - return ( - <div className="min-h-screen py-12"> - <Container> - <div className="max-w-2xl mx-auto text-center"> - <Card> - <div className="w-24 h-24 bg-kobo-gray-light/20 rounded-full flex items-center justify-center mx-auto mb-6"> - <Icon type="history" size={48} className="text-kobo-gray-light" /> - </div> - - <h2 className="text-3xl font-display font-bold text-kobo-dark mb-4">No Backup History</h2> - - <p className="text-lg text-kobo-gray mb-8"> - You haven't created any backups yet. Create your first backup to keep your Kobo library safe. - </p> - - <Button size="lg" variant="primary" onClick={() => onNavigate && onNavigate('backup')}> - <Icon type="backup" size={20} /> - Create Backup - </Button> - </Card> - </div> - </Container> - </div> - ); - } - - return ( - <div className="min-h-screen py-12"> - <Container> - <div className="max-w-4xl mx-auto"> - <div className="mb-8"> - <h1 className="text-4xl font-display font-bold text-kobo-dark mb-2">Backup History</h1> - <p className="text-lg text-kobo-gray">View and manage your Kobo library backups</p> - </div> - - {/* Backup List */} - <div className="space-y-4"> - {backups.map((backup) => ( - <BackupCard key={backup.id} backup={backup} onDelete={handleDelete} onRestore={handleRestore} /> - ))} - </div> - - {/* Info Section */} - <Card className="mt-8 bg-kobo-info/10 border-2 border-kobo-info/20"> - <div className="flex items-start gap-3"> - <Icon type="info" className="text-kobo-info shrink-0 mt-1" /> - <div> - <h4 className="font-semibold text-kobo-dark mb-2">Backup Best Practices</h4> - <ul className="text-sm text-kobo-gray space-y-1 list-disc list-inside"> - <li>Create regular backups, especially before device updates</li> - <li>Store backups in multiple locations (cloud + external drive)</li> - <li>Follow the 3-2-1 rule: 3 copies, 2 different media, 1 offsite</li> - <li>Verify backups can be restored periodically</li> - </ul> - </div> - </div> - </Card> - </div> - </Container> - - {/* Delete Confirmation Modal */} - <Modal isOpen={deleteModal.isOpen} onClose={() => setDeleteModal({ isOpen: false, backup: null })}> - <div className="text-center"> - <div className="w-16 h-16 bg-kobo-error/10 rounded-full flex items-center justify-center mx-auto mb-4"> - <Icon type="alert" size={32} className="text-kobo-error" /> - </div> - - <h3 className="text-2xl font-display font-bold text-kobo-dark mb-2">Delete Backup?</h3> - - <p className="text-kobo-gray mb-6"> - Are you sure you want to remove this backup from your history? This action cannot be undone. - </p> - - {deleteModal.backup && ( - <div className="p-4 bg-kobo-cream-dark rounded-lg mb-6 text-left"> - <p className="text-sm text-kobo-gray mb-1">Backup File</p> - <p className="font-semibold text-kobo-dark">{deleteModal.backup.filename}</p> - </div> - )} - - <div className="flex gap-3 justify-center"> - <Button variant="secondary" onClick={() => setDeleteModal({ isOpen: false, backup: null })}> - Cancel - </Button> - <Button variant="danger" onClick={confirmDelete}> - <Icon type="trash" size={16} /> - Delete - </Button> - </div> - </div> - </Modal> - </div> - ); -} - -function BackupCard({ backup, onDelete, onRestore }) { - return ( - <Card variant="elevated"> - <div className="flex flex-col sm:flex-row sm:items-center gap-4"> - {/* Backup Icon */} - <div className="w-16 h-16 bg-linear-to-br from-kobo-accent to-kobo-accent-dark rounded-xl flex items-center justify-center shrink-0"> - <Icon type="backup" size={32} className="text-white" /> - </div> - - {/* Backup Info */} - <div className="flex-1 min-w-0"> - <h3 className="text-lg font-display font-bold text-kobo-dark mb-1 truncate">{backup.filename}</h3> - <div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-kobo-gray"> - <span className="flex items-center gap-1"> - <Icon type="calendar" size={14} /> - {formatDate(backup.created)} - </span> - <span className="flex items-center gap-1"> - <Icon type="database" size={14} /> - {formatBytes(backup.size)} - </span> - <span className="flex items-center gap-1"> - <Icon type="book" size={14} /> - {backup.bookCount} books - </span> - {backup.annotationCount > 0 && ( - <span className="flex items-center gap-1"> - <Icon type="note" size={14} /> - {backup.annotationCount} annotations - </span> - )} - </div> - </div> - - {/* Actions */} - <div className="flex gap-2 shrink-0"> - <Button size="sm" variant="primary" onClick={() => onRestore(backup)} title="Restore this backup"> - <Icon type="restore" size={16} /> - Restore - </Button> - <Button size="sm" variant="ghost" onClick={() => onDelete(backup)} title="Remove from history"> - <Icon type="trash" size={16} /> - </Button> - </div> - </div> - </Card> - ); -} diff --git a/src/pages/History.tsx b/src/pages/History.tsx new file mode 100644 index 0000000..bd28ba7 --- /dev/null +++ b/src/pages/History.tsx @@ -0,0 +1,197 @@ +import { useState } from 'react'; +import { useI18n } from '../i18n/core.ts'; +import { Link } from '../router/Router.tsx'; +import { useNavigateTo } from '../router/context.ts'; +import { useKoboStore } from '../stores/koboStore.ts'; +import { deleteBackupHandle, fileFromHandle, loadBackupHandle } from '../utils/handleStore.ts'; +import { errorMessage } from '../utils/errors.ts'; +import { Container } from '../components/layout/Container.tsx'; +import { PageHeader } from '../components/common/PageHeader.tsx'; +import { Card } from '../components/common/Card.tsx'; +import { Button } from '../components/common/Button.tsx'; +import { Icon } from '../components/common/Icon.tsx'; +import { Modal } from '../components/common/Modal.tsx'; +import { Alert } from '../components/common/Alert.tsx'; +import { StatusBadge } from '../components/common/StatusBadge.tsx'; +import type { BackupHistoryEntry } from '../types/kobo.ts'; + +type Feedback = { tone: 'success' | 'error'; text: string }; + +export function History() { + const { m } = useI18n(); + const backups = useKoboStore((s) => s.backups); + const removeBackup = useKoboStore((s) => s.removeBackup); + const [toRemove, setToRemove] = useState<BackupHistoryEntry | null>(null); + + return ( + <Container size="sm" className="py-12"> + <PageHeader title={m.history.title} subtitle={m.history.subtitle} /> + + {backups.length === 0 ? ( + <Card className="text-center"> + <Icon type="history" size={48} className="mx-auto mb-4 text-kobo-gray-light" /> + <h2 className="mb-2 text-2xl text-kobo-dark">{m.history.emptyTitle}</h2> + <p className="mb-6 text-kobo-gray">{m.history.emptyBody}</p> + <Link + to="backup" + className="rounded-lg bg-kobo-accent px-6 py-3 font-semibold text-kobo-dark hover:bg-kobo-accent-dark focus-visible-ring" + > + {m.history.create} + </Link> + </Card> + ) : ( + <ul className="space-y-4"> + {backups.map((b) => ( + <li key={b.id}> + <BackupCard backup={b} onRemove={() => setToRemove(b)} /> + </li> + ))} + </ul> + )} + + <Card className="mt-8 border border-kobo-info/20 bg-kobo-info/5"> + <h2 className="mb-2 text-lg text-kobo-dark">{m.history.bestTitle}</h2> + <ul className="list-disc space-y-1 pl-5 text-sm text-kobo-gray"> + {m.history.best.map((tip) => ( + <li key={tip}>{tip}</li> + ))} + </ul> + </Card> + + <Modal + isOpen={!!toRemove} + onClose={() => setToRemove(null)} + title={m.history.removeTitle} + closeLabel={m.common.close} + size="sm" + > + <p className="mb-2 text-kobo-gray">{m.history.removeBody}</p> + <p className="mb-6 font-semibold text-kobo-dark">{toRemove?.filename}</p> + <div className="flex justify-end gap-3"> + <Button variant="secondary" onClick={() => setToRemove(null)}> + {m.common.cancel} + </Button> + <Button + variant="danger" + onClick={() => { + if (toRemove) { + removeBackup(toRemove.id); + void deleteBackupHandle(toRemove.id); + } + setToRemove(null); + }} + > + <Icon type="trash" size="sm" /> + {m.history.remove} + </Button> + </div> + </Modal> + </Container> + ); +} + +function BackupCard({ backup, onRemove }: { backup: BackupHistoryEntry; onRemove: () => void }) { + const { m, fmt, plural, formatBytes, formatDate } = useI18n(); + const navigateTo = useNavigateTo(); + const setPendingRestore = useKoboStore((s) => s.setPendingRestore); + const [busy, setBusy] = useState(false); + const [feedback, setFeedback] = useState<Feedback | null>(null); + + const openFile = async (): Promise<File | null> => { + const handle = await loadBackupHandle(backup.id); + if (!handle) return null; + try { + return await fileFromHandle(handle); + } catch { + return null; + } + }; + + const verify = async () => { + setBusy(true); + setFeedback(null); + try { + const file = await openFile(); + if (!file) return setFeedback({ tone: 'error', text: m.history.notAvailable }); + const { verifyBackupArchive } = await import('../utils/backup.ts'); + const { parseBackupFile } = await import('../utils/restore.ts'); + // Encrypted archives can only be listed; their integrity is checked on restore. + const result = await verifyBackupArchive(file, {}); + let checksumOk: boolean | null = null; + if (result.ok && !backup.encrypted) checksumOk = (await parseBackupFile(file)).checksumOk; + const ok = result.ok && checksumOk !== false; + setFeedback( + ok + ? { tone: 'success', text: m.history.verifyOk } + : { + tone: 'error', + text: fmt(m.history.verifyBad, { + details: result.error ?? result.missing.join(', ') ?? 'checksum', + }), + }, + ); + } catch (err) { + setFeedback({ tone: 'error', text: fmt(m.history.verifyBad, { details: errorMessage(err) }) }); + } finally { + setBusy(false); + } + }; + + const restore = async () => { + const file = backup.hasHandle ? await openFile() : null; + if (file) setPendingRestore(file); + else if (backup.hasHandle) return setFeedback({ tone: 'error', text: m.history.notAvailable }); + navigateTo('restore'); + }; + + return ( + <Card> + <div className="flex flex-col gap-4 sm:flex-row sm:items-center"> + <div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl bg-linear-to-br from-kobo-accent to-kobo-accent-dark"> + <Icon type="backup" size={28} className="text-white" /> + </div> + <div className="min-w-0 flex-1"> + <h2 className="truncate font-display text-lg font-bold text-kobo-dark">{backup.filename}</h2> + <p className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-kobo-gray"> + <span>{formatDate(backup.created, { dateStyle: 'medium', timeStyle: 'short' })}</span> + <span>{formatBytes(backup.size)}</span> + <span>{plural(m.common.books, backup.bookCount)}</span> + {backup.annotationCount > 0 && ( + <span>{plural(m.common.annotations, backup.annotationCount)}</span> + )} + </p> + <p className="mt-2 flex flex-wrap gap-2"> + <StatusBadge status="default">{backup.deviceModel}</StatusBadge> + {backup.encrypted && <StatusBadge status="warning">{m.history.encrypted}</StatusBadge>} + {backup.verified && <StatusBadge status="success">{m.history.verified}</StatusBadge>} + </p> + </div> + <div className="flex shrink-0 gap-2"> + {backup.hasHandle && ( + <Button size="sm" variant="secondary" onClick={verify} loading={busy}> + {busy ? m.history.verifying : m.history.verify} + </Button> + )} + <Button size="sm" onClick={restore}> + <Icon type="restore" size="sm" /> + {m.history.restore} + </Button> + <Button + size="sm" + variant="ghost" + onClick={onRemove} + aria-label={m.history.remove} + title={m.history.remove} + > + <Icon type="trash" size="sm" /> + </Button> + </div> + </div> + {feedback && ( + <Alert tone={feedback.tone} className="mt-4" live> + {feedback.text} + </Alert> + )} + </Card> + ); +} diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx deleted file mode 100644 index a84efc7..0000000 --- a/src/pages/Home.jsx +++ /dev/null @@ -1,229 +0,0 @@ -/** - * Home page - Landing with value proposition - */ - -import { Button } from '../components/common/Button.tsx'; -import { Card } from '../components/common/Card.tsx'; -import { Container } from '../components/layout/Container.tsx'; -import { Icon } from '../components/common/Icon.tsx'; - -import { useKoboStore } from '../stores/koboStore.ts'; - -export function Home({ onNavigate }) { - const books = useKoboStore((state) => state.books); - const device = useKoboStore((state) => state.device); - - return ( - <div className="min-h-screen"> - {/* Active Session Banner */} - {books && books.length > 0 && ( - <div className="bg-kobo-accent/10 border-b border-kobo-accent/20 py-3 text-center"> - <Container> - <div className="flex flex-col sm:flex-row justify-center items-center gap-3 text-sm font-body text-kobo-dark"> - <span> - 📲 Connected to <strong className="font-semibold">{device?.model || 'Kobo Device'}</strong>{' '} - with <strong className="font-semibold">{books.length} books</strong>. - </span> - <button - onClick={() => onNavigate('dashboard')} - className="bg-kobo-accent text-white px-3.5 py-1.5 rounded-lg text-xs font-semibold hover:bg-kobo-accent-dark transition-colors shadow-xs" - > - Open Dashboard - </button> - </div> - </Container> - </div> - )} - - {/* Hero Section */} - <section className="bg-linear-to-b from-kobo-cream to-white py-16 sm:py-24"> - <Container> - <div className="text-center animate-fade-in"> - <h1 className="text-4xl sm:text-5xl lg:text-6xl font-display font-display font-bold text-kobo-dark mb-6"> - Never Lose Your - <br /> - <span className="text-kobo-accent">Kobo Library</span> Again - </h1> - - <p className="text-xl sm:text-2xl text-kobo-gray max-w-3xl mx-auto mb-8"> - Free, secure backup for all your ebooks, annotations, and reading progress. Works 100% in your - browser. - </p> - - <div className="flex flex-col sm:flex-row gap-4 justify-center items-center mb-8"> - <Button - size="lg" - variant="primary" - onClick={() => onNavigate('backup')} - className="w-full sm:w-auto" - > - <Icon type="download" size={20} /> - Create Backup - </Button> - <Button - size="lg" - variant="secondary" - onClick={() => onNavigate('restore')} - className="w-full sm:w-auto" - > - <Icon type="upload" size={20} /> - Restore Backup - </Button> - </div> - - {/* Trust Badges */} - <div className="flex flex-wrap justify-center gap-6 text-sm text-kobo-gray"> - <div className="flex items-center gap-2"> - <Icon type="check" size={16} className="text-kobo-success" /> - <span>No Installation</span> - </div> - <div className="flex items-center gap-2"> - <Icon type="check" size={16} className="text-kobo-success" /> - <span>100% Private</span> - </div> - <div className="flex items-center gap-2"> - <Icon type="check" size={16} className="text-kobo-success" /> - <span>Open Source</span> - </div> - </div> - </div> - </Container> - </section> - - {/* Features Section */} - <section className="py-16"> - <Container> - <h2 className="text-3xl sm:text-4xl font-display font-bold text-kobo-dark text-center mb-12"> - Why Use Kobo Backup Manager? - </h2> - - <div className="grid grid-cols-1 md:grid-cols-3 gap-8"> - <Card gradient className="text-center"> - <div className="w-16 h-16 bg-kobo-accent/10 rounded-full flex items-center justify-center mx-auto mb-4"> - <Icon type="device" size={32} className="text-kobo-accent" /> - </div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-3">Complete Protection</h3> - <p className="text-kobo-gray"> - Backup all your sideloaded books, annotations, highlights, and reading progress in one secure - archive. - </p> - </Card> - - <Card gradient className="text-center"> - <div className="w-16 h-16 bg-kobo-success/10 rounded-full flex items-center justify-center mx-auto mb-4"> - <Icon type="check" size={32} className="text-kobo-success" /> - </div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-3">Privacy First</h3> - <p className="text-kobo-gray"> - All processing happens in your browser. Your data never leaves your device - no servers, no - tracking, no accounts. - </p> - </Card> - - <Card gradient className="text-center"> - <div className="w-16 h-16 bg-kobo-info/10 rounded-full flex items-center justify-center mx-auto mb-4"> - <Icon type="restore" size={32} className="text-kobo-info" /> - </div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-3">Easy Restore</h3> - <p className="text-kobo-gray"> - Moving to a new Kobo? Restore your entire library with just a few clicks. Pick up right where - you left off. - </p> - </Card> - </div> - </Container> - </section> - - {/* How It Works */} - <section className="py-16 bg-kobo-cream-dark/30"> - <Container> - <h2 className="text-3xl sm:text-4xl font-display font-bold text-kobo-dark text-center mb-12"> - How It Works - </h2> - - <div className="max-w-4xl mx-auto space-y-8"> - <div className="flex gap-6 items-start"> - <div className="shrink-0 w-12 h-12 bg-kobo-accent text-white rounded-full flex items-center justify-center font-bold text-xl"> - 1 - </div> - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Connect Your Kobo</h3> - <p className="text-kobo-gray"> - Plug your Kobo into your computer via USB and unlock it. The device will appear as a drive. - </p> - </div> - </div> - - <div className="flex gap-6 items-start"> - <div className="shrink-0 w-12 h-12 bg-kobo-accent text-white rounded-full flex items-center justify-center font-bold text-xl"> - 2 - </div> - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Select Your Device</h3> - <p className="text-kobo-gray"> - Click the button to select your Kobo drive. The app will automatically find your books and - reading data. - </p> - </div> - </div> - - <div className="flex gap-6 items-start"> - <div className="shrink-0 w-12 h-12 bg-kobo-accent text-white rounded-full flex items-center justify-center font-bold text-xl"> - 3 - </div> - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Create Backup</h3> - <p className="text-kobo-gray"> - Everything is packaged into a single ZIP file and saved to your Downloads folder. That's it! - </p> - </div> - </div> - </div> - - <div className="text-center mt-12"> - <Button size="lg" variant="primary" onClick={() => onNavigate('backup')}> - Get Started Now - </Button> - </div> - </Container> - </section> - - {/* FAQ Teaser */} - <section className="py-16"> - <Container size="sm"> - <h2 className="text-3xl sm:text-4xl font-display font-bold text-kobo-dark text-center mb-8"> - Frequently Asked Questions - </h2> - - <div className="space-y-4"> - <Card> - <h3 className="font-display font-bold text-kobo-dark mb-2">Is my data safe?</h3> - <p className="text-kobo-gray"> - Absolutely! All processing happens locally in your browser. Your books and data never leave - your computer. We don't have any servers to send data to. - </p> - </Card> - - <Card> - <h3 className="font-display font-bold text-kobo-dark mb-2"> - Does this work with Kobo store books? - </h3> - <p className="text-kobo-gray"> - This tool is designed for sideloaded books (books you added yourself). Kobo store books are - already backed up in your Kobo account. - </p> - </Card> - - <Card> - <h3 className="font-display font-bold text-kobo-dark mb-2">Which browsers are supported?</h3> - <p className="text-kobo-gray"> - For the best experience, use Chrome 86+, Edge 86+, or Opera 72+. Other browsers may work with - limited functionality. - </p> - </Card> - </div> - </Container> - </section> - </div> - ); -} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx new file mode 100644 index 0000000..bc7b215 --- /dev/null +++ b/src/pages/Home.tsx @@ -0,0 +1,145 @@ +import { Link } from '../router/Router.tsx'; +import { useI18n } from '../i18n/core.ts'; +import { Container } from '../components/layout/Container.tsx'; +import { Card } from '../components/common/Card.tsx'; +import { Icon, type IconType } from '../components/common/Icon.tsx'; +import { useKoboStore } from '../stores/koboStore.ts'; + +const FEATURE_ICONS: IconType[] = ['backup', 'shield', 'lock', 'restore', 'book', 'check']; + +const cta = + 'inline-flex w-full items-center justify-center gap-2 rounded-lg px-6 py-3 text-lg font-semibold transition-all-smooth focus-visible-ring sm:w-auto'; + +export function Home() { + const { m, fmt, plural } = useI18n(); + const scan = useKoboStore((s) => s.scan); + const faqTeaser = m.faq.categories.flatMap((c) => c.items).slice(0, 4); + + return ( + <> + {scan && ( + <div className="border-b border-kobo-accent/30 bg-kobo-accent/10 py-3 text-center text-sm text-kobo-dark"> + <Container> + {fmt(m.connect.connected, { model: scan.deviceInfo.model })} ·{' '} + {plural(m.common.books, scan.books.length)} ·{' '} + <Link to="library" className="font-semibold text-kobo-accent-ink underline"> + {m.home.ctaLibrary} + </Link> + </Container> + </div> + )} + + <section className="bg-linear-to-b from-kobo-cream to-white py-16 sm:py-24"> + <Container className="text-center"> + <h1 className="mb-6 text-4xl text-kobo-dark sm:text-5xl lg:text-6xl"> + {m.home.heroTitle} + <br /> + <span className="text-kobo-accent-ink">{m.home.heroHighlight}</span> + </h1> + <p className="mx-auto mb-8 max-w-3xl text-xl text-kobo-gray sm:text-2xl">{m.home.heroBody}</p> + <div className="mb-8 flex flex-col items-center justify-center gap-4 sm:flex-row"> + <Link to="backup" className={`${cta} bg-kobo-accent text-kobo-dark hover:bg-kobo-accent-dark`}> + <Icon type="download" /> + {m.home.ctaBackup} + </Link> + <Link + to="restore" + className={`${cta} border-2 border-kobo-gray-light bg-white text-kobo-dark hover:bg-kobo-cream`} + > + <Icon type="upload" /> + {m.home.ctaRestore} + </Link> + </div> + <ul className="flex flex-wrap justify-center gap-6 text-sm text-kobo-gray"> + {m.home.trust.map((item) => ( + <li key={item} className="flex items-center gap-2"> + <Icon type="check" size="sm" className="text-kobo-success" /> + {item} + </li> + ))} + </ul> + </Container> + </section> + + <section className="py-16" aria-labelledby="features-title"> + <Container> + <h2 id="features-title" className="mb-12 text-center text-3xl text-kobo-dark sm:text-4xl"> + {m.home.featuresTitle} + </h2> + <ul className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3"> + {m.home.features.map((feature, i) => ( + <li key={feature.title}> + <Card gradient className="h-full"> + <div className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-kobo-accent/15"> + <Icon type={FEATURE_ICONS[i] ?? 'check'} size={28} className="text-kobo-accent-ink" /> + </div> + <h3 className="mb-2 text-xl text-kobo-dark">{feature.title}</h3> + <p className="text-kobo-gray">{feature.body}</p> + </Card> + </li> + ))} + </ul> + </Container> + </section> + + <section className="bg-kobo-cream-dark/40 py-16" aria-labelledby="how-title"> + <Container size="sm"> + <h2 id="how-title" className="mb-12 text-center text-3xl text-kobo-dark sm:text-4xl"> + {m.home.howTitle} + </h2> + <ol className="space-y-8"> + {m.home.how.map((step, i) => ( + <li key={step.title} className="flex items-start gap-6"> + <span + className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-kobo-accent text-xl font-bold text-kobo-dark" + aria-hidden="true" + > + {i + 1} + </span> + <div> + <h3 className="mb-1 text-xl text-kobo-dark">{step.title}</h3> + <p className="text-kobo-gray">{step.body}</p> + </div> + </li> + ))} + </ol> + <div className="mt-12 text-center"> + <Link to="backup" className={`${cta} bg-kobo-accent text-kobo-dark hover:bg-kobo-accent-dark`}> + {m.home.ctaBackup} + </Link> + </div> + </Container> + </section> + + <section className="py-16" aria-labelledby="faq-title"> + <Container size="sm"> + <h2 id="faq-title" className="mb-8 text-center text-3xl text-kobo-dark sm:text-4xl"> + {m.home.faqTitle} + </h2> + <div className="space-y-3"> + {faqTeaser.map((item) => ( + <details + key={item.q} + className="group rounded-xl border border-kobo-cream-dark bg-white p-5 shadow-xs" + > + <summary className="flex cursor-pointer list-none items-center justify-between gap-4 font-display text-lg font-bold text-kobo-dark"> + {item.q} + <Icon + type="chevronDown" + className="shrink-0 text-kobo-accent-ink transition-transform group-open:rotate-180" + /> + </summary> + <p className="mt-3 text-kobo-gray">{item.a}</p> + </details> + ))} + </div> + <p className="mt-6 text-center"> + <Link to="faq" className="font-semibold text-kobo-accent-ink underline"> + {m.home.faqMore} + </Link> + </p> + </Container> + </section> + </> + ); +} diff --git a/src/pages/Library.tsx b/src/pages/Library.tsx new file mode 100644 index 0000000..30d074d --- /dev/null +++ b/src/pages/Library.tsx @@ -0,0 +1,635 @@ +import { useDeferredValue, useMemo, useState } from 'react'; +import { useI18n } from '../i18n/core.ts'; +import { Link } from '../router/Router.tsx'; +import { useDevice } from '../hooks/useDevice.ts'; +import { useKoboStore } from '../stores/koboStore.ts'; +import { downloadBlob } from '../utils/fileSystem.ts'; +import { errorMessage } from '../utils/errors.ts'; +import { Container } from '../components/layout/Container.tsx'; +import { PageHeader } from '../components/common/PageHeader.tsx'; +import { Card } from '../components/common/Card.tsx'; +import { Button } from '../components/common/Button.tsx'; +import { Icon } from '../components/common/Icon.tsx'; +import { Modal } from '../components/common/Modal.tsx'; +import { Tabs } from '../components/common/Tabs.tsx'; +import { Alert } from '../components/common/Alert.tsx'; +import { ConnectDevice } from '../components/device/ConnectDevice.tsx'; +import { CoverImage } from '../components/library/CoverImage.tsx'; +import type { KoboAnnotation, KoboBook, ScanResult } from '../types/kobo.ts'; +import type { KoboSource } from '../utils/deviceSource.ts'; + +type Tab = 'books' | 'annotations' | 'collections' | 'stats'; +type Filter = 'all' | 'reading' | 'finished' | 'unread'; +type Sort = 'recent' | 'title' | 'author' | 'progress'; + +const isFinished = (b: KoboBook) => b.ReadStatus === 2 || b.Progress >= 100; +const isReading = (b: KoboBook) => !isFinished(b) && (b.ReadStatus === 1 || b.Progress > 0); +const FILTERS: Record<Filter, (b: KoboBook) => boolean> = { + all: () => true, + reading: isReading, + finished: isFinished, + unread: (b) => !isFinished(b) && !isReading(b), +}; + +const today = () => new Date().toISOString().split('T')[0]; + +export function Library() { + const { m } = useI18n(); + const device = useDevice(); + const source = useKoboStore((s) => s.source); + + return ( + <Container className="py-12"> + <PageHeader title={m.library.title} subtitle={m.library.subtitle} /> + {device.scan ? ( + <Dashboard scan={device.scan} source={source} onDisconnect={device.disconnect} /> + ) : ( + <ConnectDevice device={device} /> + )} + </Container> + ); +} + +function Dashboard({ + scan, + source, + onDisconnect, +}: { + scan: ScanResult; + source: KoboSource | null; + onDisconnect: () => void; +}) { + const { m, fmt, plural } = useI18n(); + const [tab, setTab] = useState<Tab>('books'); + const [selected, setSelected] = useState<KoboBook | null>(null); + + const annotationsByBook = useMemo(() => { + const map: Record<string, KoboAnnotation[]> = {}; + for (const a of scan.annotations) (map[a.VolumeID] ||= []).push(a); + return map; + }, [scan.annotations]); + + return ( + <> + <div className="mb-6 flex flex-col items-center justify-between gap-4 rounded-xl bg-white p-4 shadow-xs sm:flex-row"> + <p className="text-kobo-dark"> + <Icon type="device" className="mr-2 inline text-kobo-accent-ink" /> + {fmt(m.library.connected, { + model: scan.deviceInfo.model, + firmware: scan.deviceInfo.firmwareVersion, + })} + </p> + <div className="flex gap-2"> + <Link + to="backup" + className="rounded-lg bg-kobo-accent px-4 py-2 font-semibold text-kobo-dark hover:bg-kobo-accent-dark focus-visible-ring" + > + {m.library.backupCta} + </Link> + <Button variant="ghost" onClick={onDisconnect}> + {m.connect.disconnect} + </Button> + </div> + </div> + + <Tabs<Tab> + label={m.library.title} + active={tab} + onChange={setTab} + tabs={[ + { id: 'books', label: `${m.library.tabs.books} (${scan.books.length})` }, + { id: 'annotations', label: `${m.library.tabs.annotations} (${scan.annotations.length})` }, + { id: 'collections', label: `${m.library.tabs.collections} (${scan.collections.length})` }, + { id: 'stats', label: m.library.tabs.stats }, + ]} + > + {tab === 'books' && ( + <BooksTab + scan={scan} + source={source} + annotationsByBook={annotationsByBook} + onSelect={setSelected} + /> + )} + {tab === 'annotations' && ( + <AnnotationsTab scan={scan} source={source} annotationsByBook={annotationsByBook} /> + )} + {tab === 'collections' && <CollectionsTab scan={scan} onSelect={setSelected} />} + {tab === 'stats' && <StatsTab scan={scan} />} + </Tabs> + + <Modal + isOpen={!!selected} + onClose={() => setSelected(null)} + title={m.library.details} + closeLabel={m.common.close} + size="lg" + > + {selected && ( + <BookDetails + book={selected} + source={source} + annotations={annotationsByBook[selected.ContentID] ?? []} + /> + )} + </Modal> + <p className="sr-only" aria-live="polite"> + {plural(m.library.results, scan.books.length)} + </p> + </> + ); +} + +function BooksTab({ + scan, + source, + annotationsByBook, + onSelect, +}: { + scan: ScanResult; + source: KoboSource | null; + annotationsByBook: Record<string, KoboAnnotation[]>; + onSelect: (b: KoboBook) => void; +}) { + const { m, fmt, plural, locale } = useI18n(); + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState<Filter>('all'); + const [sort, setSort] = useState<Sort>('recent'); + const deferredQuery = useDeferredValue(query); + + const books = useMemo(() => { + const q = deferredQuery.trim().toLocaleLowerCase(locale); + const collator = new Intl.Collator(locale, { sensitivity: 'base' }); + const sorters: Record<Sort, (a: KoboBook, b: KoboBook) => number> = { + recent: (a, b) => (b.DateLastRead?.getTime() ?? 0) - (a.DateLastRead?.getTime() ?? 0), + title: (a, b) => collator.compare(a.Title ?? '', b.Title ?? ''), + author: (a, b) => collator.compare(a.Author, b.Author), + progress: (a, b) => b.Progress - a.Progress, + }; + return scan.books + .filter((b) => FILTERS[filter](b)) + .filter((b) => !q || `${b.Title} ${b.Author}`.toLocaleLowerCase(locale).includes(q)) + .sort(sorters[sort]); + }, [scan.books, deferredQuery, filter, sort, locale]); + + const selectClass = + 'rounded-lg border border-kobo-gray-light bg-white px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-kobo-accent/50'; + + return ( + <div className="space-y-6"> + <div className="flex flex-col gap-3 rounded-xl bg-white p-4 shadow-xs sm:flex-row sm:items-end"> + <label className="flex-1 text-sm font-medium text-kobo-dark"> + {m.library.search} + <span className="relative mt-1 block"> + <Icon type="search" className="pointer-events-none absolute left-3 top-2.5 text-kobo-gray" /> + <input + type="search" + value={query} + onChange={(e) => setQuery(e.target.value)} + className="w-full rounded-lg border border-kobo-gray-light py-2 pl-10 pr-3 focus:outline-hidden focus:ring-2 focus:ring-kobo-accent/50" + /> + </span> + </label> + <label className="text-sm font-medium text-kobo-dark"> + {m.library.filter} + <select + value={filter} + onChange={(e) => setFilter(e.target.value as Filter)} + className={`mt-1 block ${selectClass}`} + > + {(Object.keys(FILTERS) as Filter[]).map((f) => ( + <option key={f} value={f}> + {m.library.filters[f]} + </option> + ))} + </select> + </label> + <label className="text-sm font-medium text-kobo-dark"> + {m.library.sort} + <select + value={sort} + onChange={(e) => setSort(e.target.value as Sort)} + className={`mt-1 block ${selectClass}`} + > + {(['recent', 'title', 'author', 'progress'] as Sort[]).map((s) => ( + <option key={s} value={s}> + {m.library.sorts[s]} + </option> + ))} + </select> + </label> + </div> + + <p className="text-sm text-kobo-gray" aria-live="polite"> + {plural(m.library.results, books.length)} + </p> + + {books.length === 0 ? ( + <p className="rounded-xl bg-white p-12 text-center text-kobo-gray">{m.library.noResults}</p> + ) : ( + <ul className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6"> + {books.map((book) => { + const count = annotationsByBook[book.ContentID]?.length ?? 0; + return ( + <li + key={book.ContentID} + className="[content-visibility:auto] [contain-intrinsic-size:auto_320px]" + > + <button + type="button" + onClick={() => onSelect(book)} + className="group flex w-full flex-col rounded-md text-left focus-visible-ring" + > + <CoverImage + source={source} + coverId={book.CoverId} + title={book.Title ?? ''} + author={book.Author} + className="mb-3 transition-transform group-hover:scale-[1.03]" + /> + <span className="line-clamp-2 font-display text-sm font-bold leading-tight text-kobo-dark"> + {book.Title} + </span> + <span className="mt-1 truncate text-xs text-kobo-gray">{book.Author}</span> + <span + className="mt-2 block h-1.5 w-full overflow-hidden rounded-full bg-kobo-cream-dark" + aria-hidden="true" + > + <span + className={`block h-full rounded-full ${isFinished(book) ? 'bg-kobo-success' : 'bg-kobo-accent'}`} + style={{ width: `${Math.min(book.Progress, 100)}%` }} + /> + </span> + <span className="mt-1 flex justify-between text-xs text-kobo-gray"> + <span>{fmt(m.library.read, { percent: book.Progress })}</span> + {count > 0 && <span>{plural(m.common.annotations, count)}</span>} + </span> + </button> + </li> + ); + })} + </ul> + )} + </div> + ); +} + +function AnnotationList({ annotations }: { annotations: KoboAnnotation[] }) { + const { m, formatDate } = useI18n(); + const [copied, setCopied] = useState<string | null>(null); + + const copy = async (a: KoboAnnotation) => { + await navigator.clipboard?.writeText([a.HighlightedText, a.Note].filter(Boolean).join('\n\n')); + setCopied(a.BookmarkID); + setTimeout(() => setCopied((c) => (c === a.BookmarkID ? null : c)), 2000); + }; + + return ( + <ul className="space-y-3"> + {annotations.map((a) => ( + <li + key={a.BookmarkID} + className="rounded-lg border border-kobo-cream-dark bg-kobo-cream/50 p-3 text-sm" + > + {a.HighlightedText && ( + <blockquote className="whitespace-pre-line border-l-2 border-kobo-accent pl-3 italic text-kobo-dark"> + {a.HighlightedText} + </blockquote> + )} + {a.Note && ( + <p className="mt-2 rounded bg-white/80 p-2 text-kobo-dark"> + <span className="mb-1 block text-xs font-bold uppercase tracking-wider text-kobo-accent-ink"> + {m.library.note} + </span> + {a.Note} + </p> + )} + <div className="mt-2 flex items-center justify-between text-xs text-kobo-gray"> + <span>{a.DateCreated ? formatDate(a.DateCreated) : ''}</span> + <button + type="button" + onClick={() => void copy(a)} + className="flex items-center gap-1 rounded px-2 py-1 font-semibold hover:bg-kobo-cream-dark focus-visible-ring" + aria-live="polite" + > + <Icon type={copied === a.BookmarkID ? 'check' : 'copy'} size="sm" /> + {copied === a.BookmarkID ? m.library.copied : m.library.copy} + </button> + </div> + </li> + ))} + </ul> + ); +} + +async function exportBookMarkdown(book: KoboBook, annotations: KoboAnnotation[]) { + const { generateObsidianMarkdown, slugify } = await import('../utils/export.ts'); + downloadBlob( + new Blob([generateObsidianMarkdown(book, annotations)], { type: 'text/markdown;charset=utf-8' }), + `${slugify(book.Title)}.md`, + ); +} + +function AnnotationsTab({ + scan, + source, + annotationsByBook, +}: { + scan: ScanResult; + source: KoboSource | null; + annotationsByBook: Record<string, KoboAnnotation[]>; +}) { + const { m, fmt, plural } = useI18n(); + const [open, setOpen] = useState<string | null>(null); + const [exportError, setExportError] = useState<string | null>(null); + const books = scan.books.filter((b) => annotationsByBook[b.ContentID]?.length); + + const run = async (fn: () => Promise<void>) => { + setExportError(null); + try { + await fn(); + } catch (err) { + setExportError(fmt(m.library.exportFailed, { error: errorMessage(err) })); + } + }; + + if (scan.annotations.length === 0) + return <p className="rounded-xl bg-white p-12 text-center text-kobo-gray">{m.library.noAnnotations}</p>; + + return ( + <div className="space-y-6"> + <Card className="flex flex-col items-center justify-between gap-4 border border-kobo-accent/30 bg-kobo-accent/5 sm:flex-row"> + <div> + <h2 className="text-xl text-kobo-dark">{m.library.exportTitle}</h2> + <p className="text-sm text-kobo-gray">{m.library.exportBody}</p> + </div> + <div className="flex flex-col gap-2 sm:flex-row"> + <Button + size="sm" + onClick={() => + run(async () => { + const { exportToObsidianZip } = await import('../utils/export.ts'); + downloadBlob( + await exportToObsidianZip(scan.books, annotationsByBook), + `kobo_obsidian_notes_${today()}.zip`, + ); + }) + } + > + {m.library.exportObsidian} + </Button> + <Button + size="sm" + variant="secondary" + onClick={() => + run(async () => { + const { exportToAnkiCsv } = await import('../utils/export.ts'); + downloadBlob(exportToAnkiCsv(scan.annotations), `kobo_anki_flashcards_${today()}.csv`); + }) + } + > + {m.library.exportAnki} + </Button> + </div> + </Card> + {exportError && ( + <Alert tone="error" live> + {exportError} + </Alert> + )} + + <ul className="space-y-3"> + {books.map((book) => { + const anns = annotationsByBook[book.ContentID]!; + const expanded = open === book.ContentID; + const panelId = `ann-${book.ContentID.replace(/[^a-z0-9]/gi, '')}`; + return ( + <li key={book.ContentID} className="rounded-xl bg-white shadow-xs"> + <div className="flex items-center gap-4 p-4"> + <div className="w-12 shrink-0"> + <CoverImage + source={source} + coverId={book.CoverId} + title={book.Title ?? ''} + author={book.Author} + /> + </div> + <button + type="button" + className="min-w-0 flex-1 rounded text-left focus-visible-ring" + aria-expanded={expanded} + aria-controls={panelId} + onClick={() => setOpen(expanded ? null : book.ContentID)} + > + <span className="block truncate font-display font-bold text-kobo-dark">{book.Title}</span> + <span className="block truncate text-sm text-kobo-gray"> + {book.Author} · {plural(m.common.annotations, anns.length)} + </span> + </button> + <Button size="sm" variant="ghost" onClick={() => run(() => exportBookMarkdown(book, anns))}> + {m.library.exportBook} + </Button> + <Icon + type="chevronDown" + className={`shrink-0 text-kobo-gray transition-transform ${expanded ? 'rotate-180' : ''}`} + /> + </div> + {expanded && ( + <div id={panelId} className="border-t border-kobo-cream-dark p-4"> + <AnnotationList annotations={anns} /> + </div> + )} + </li> + ); + })} + </ul> + </div> + ); +} + +function CollectionsTab({ scan, onSelect }: { scan: ScanResult; onSelect: (b: KoboBook) => void }) { + const { m, plural } = useI18n(); + const byId = useMemo(() => new Map(scan.books.map((b) => [b.ContentID, b])), [scan.books]); + if (scan.collections.length === 0) + return <p className="rounded-xl bg-white p-12 text-center text-kobo-gray">{m.library.noCollections}</p>; + + return ( + <ul className="grid gap-4 md:grid-cols-2"> + {scan.collections.map((c) => { + const books = c.ContentIds.map((id) => byId.get(id)).filter((b): b is KoboBook => !!b); + return ( + <li key={c.Id}> + <Card> + <h2 className="mb-1 text-xl text-kobo-dark">{c.Name}</h2> + <p className="mb-3 text-sm text-kobo-gray">{plural(m.common.books, c.ContentIds.length)}</p> + <ul className="space-y-1 text-sm"> + {books.map((b) => ( + <li key={b.ContentID}> + <button + type="button" + onClick={() => onSelect(b)} + className="text-left text-kobo-accent-ink underline-offset-2 hover:underline focus-visible-ring" + > + {b.Title} + </button> + </li> + ))} + </ul> + </Card> + </li> + ); + })} + </ul> + ); +} + +function StatsTab({ scan }: { scan: ScanResult }) { + const { m, plural, formatDuration, formatNumber } = useI18n(); + const total = scan.books.length || 1; + const counts = { + finished: scan.books.filter(isFinished).length, + reading: scan.books.filter(isReading).length, + unread: scan.books.filter(FILTERS.unread).length, + }; + const topAuthors = Object.entries( + scan.books.reduce<Record<string, number>>( + (acc, b) => ({ ...acc, [b.Author]: (acc[b.Author] ?? 0) + 1 }), + {}, + ), + ) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); + + const overview: [string, string][] = [ + [m.backup.stats.timeRead, formatDuration(scan.stats.totalMinutesRead)], + [m.backup.stats.books, formatNumber(scan.books.length)], + [m.backup.stats.finished, formatNumber(counts.finished)], + [m.backup.stats.annotations, formatNumber(scan.annotations.length)], + ]; + const bars: [string, number, string][] = [ + [m.library.finishedLabel, counts.finished, 'bg-kobo-success'], + [m.library.readingLabel, counts.reading, 'bg-kobo-accent'], + [m.library.unreadLabel, counts.unread, 'bg-kobo-gray-light'], + ]; + + return ( + <div className="grid gap-6 md:grid-cols-3"> + <Card className="md:col-span-3"> + <h2 className="mb-6 text-xl text-kobo-dark">{m.library.statsOverview}</h2> + <dl className="grid grid-cols-2 gap-4 text-center md:grid-cols-4"> + {overview.map(([label, value]) => ( + <div key={label} className="rounded-xl bg-kobo-cream p-4"> + <dt className="mb-1 text-sm text-kobo-gray">{label}</dt> + <dd className="font-display text-2xl text-kobo-dark">{value}</dd> + </div> + ))} + </dl> + </Card> + <Card> + <h2 className="mb-4 text-xl text-kobo-dark">{m.library.progressBreakdown}</h2> + <ul className="space-y-4"> + {bars.map(([label, count, color]) => ( + <li key={label}> + <div className="mb-1 flex justify-between text-sm"> + <span className="text-kobo-gray">{label}</span> + <span className="font-semibold text-kobo-dark">{plural(m.common.books, count)}</span> + </div> + <div className="h-2 overflow-hidden rounded-full bg-kobo-cream-dark" aria-hidden="true"> + <div className={`h-full ${color}`} style={{ width: `${(count / total) * 100}%` }} /> + </div> + </li> + ))} + </ul> + </Card> + <Card className="md:col-span-2"> + <h2 className="mb-4 text-xl text-kobo-dark">{m.library.topAuthors}</h2> + <ol className="space-y-2"> + {topAuthors.map(([author, count]) => ( + <li + key={author} + className="flex items-center justify-between rounded-lg bg-kobo-cream/60 px-3 py-2 text-sm" + > + <span className="font-semibold text-kobo-dark">{author}</span> + <span className="rounded-full bg-kobo-accent px-2 py-0.5 text-xs font-semibold text-kobo-dark"> + {plural(m.common.books, count)} + </span> + </li> + ))} + </ol> + </Card> + </div> + ); +} + +function BookDetails({ + book, + source, + annotations, +}: { + book: KoboBook; + source: KoboSource | null; + annotations: KoboAnnotation[]; +}) { + const { m, fmt, formatDate, formatDuration } = useI18n(); + const [exportError, setExportError] = useState<string | null>(null); + const details: [string, string][] = [ + [m.library.timeRead, formatDuration(book.TimeSpentReading)], + [m.library.format, (book.MimeType?.split('/').pop() ?? 'epub').replace('application/', '').toUpperCase()], + [m.library.lastRead, book.DateLastRead ? formatDate(book.DateLastRead) : m.library.never], + [m.library.isbn, book.ISBN || '—'], + [m.library.publisher, book.Publisher || '—'], + [ + m.library.series, + book.Series ? `${book.Series}${book.SeriesNumber ? ` #${book.SeriesNumber}` : ''}` : '—', + ], + ]; + + return ( + <div className="flex max-h-[70vh] flex-col gap-6 overflow-y-auto md:flex-row"> + <div className="mx-auto w-40 shrink-0"> + <CoverImage source={source} coverId={book.CoverId} title={book.Title ?? ''} author={book.Author} /> + <p className="mt-3 text-center font-display text-2xl text-kobo-accent-ink"> + {fmt(m.library.read, { percent: book.Progress })} + </p> + </div> + <div className="min-w-0 flex-1 space-y-4"> + <div> + <h3 className="text-2xl leading-tight text-kobo-dark">{book.Title}</h3> + <p className="text-lg text-kobo-gray">{book.Author}</p> + </div> + <dl className="grid grid-cols-2 gap-3 rounded-xl bg-kobo-cream/40 p-4 text-sm"> + {details.map(([label, value]) => ( + <div key={label}> + <dt className="text-xs text-kobo-gray">{label}</dt> + <dd className="truncate font-semibold text-kobo-dark">{value}</dd> + </div> + ))} + </dl> + <div> + <div className="mb-2 flex items-center justify-between"> + <h4 className="text-base text-kobo-dark"> + {m.library.tabs.annotations} ({annotations.length}) + </h4> + {annotations.length > 0 && ( + <Button + size="sm" + variant="ghost" + onClick={() => + exportBookMarkdown(book, annotations).catch((e: unknown) => setExportError(errorMessage(e))) + } + > + {m.library.exportBook} + </Button> + )} + </div> + {exportError && <Alert tone="error">{fmt(m.library.exportFailed, { error: exportError })}</Alert>} + {annotations.length ? ( + <AnnotationList annotations={annotations} /> + ) : ( + <p className="text-sm italic text-kobo-gray">{m.library.noAnnotationsForBook}</p> + )} + </div> + </div> + </div> + ); +} diff --git a/src/pages/LibraryDashboard.jsx b/src/pages/LibraryDashboard.jsx deleted file mode 100644 index 908f417..0000000 --- a/src/pages/LibraryDashboard.jsx +++ /dev/null @@ -1,705 +0,0 @@ -import { useState, useMemo } from 'react'; -import { useKoboStore } from '../stores/koboStore.ts'; -import { CoverPreview } from '../components/backup/CoverPreview.jsx'; -import { Container } from '../components/layout/Container.tsx'; -import { Card } from '../components/common/Card.tsx'; -import { Button } from '../components/common/Button.tsx'; -import { formatDuration } from '../utils/formatters.ts'; -import { exportToObsidianZip, exportToAnkiCsv } from '../utils/export.ts'; - -export function LibraryDashboard({ onNavigate }) { - const books = useKoboStore((state) => state.books); - const annotations = useKoboStore((state) => state.annotations); - const stats = useKoboStore((state) => state.stats); - const device = useKoboStore((state) => state.device); - const source = useKoboStore((state) => state.source); - - // Tab state: 'books' | 'annotations' | 'stats' - const [activeTab, setActiveTab] = useState('books'); - - // Search & Filter state - const [searchQuery, setSearchQuery] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); // all | reading | finished | unread - const [sortBy, setSortBy] = useState('recent'); // recent | title | progress - - // Selected book for details modal - const [selectedBook, setSelectedBook] = useState(null); - - // Copy success feedback state - const [copiedAnnotationId, setCopiedAnnotationId] = useState(null); - - // Filtered and sorted books - const filteredBooks = useMemo(() => { - return books - .filter((book) => { - const matchesSearch = - book.Title?.toLowerCase().includes(searchQuery.toLowerCase()) || - book.Author?.toLowerCase().includes(searchQuery.toLowerCase()); - - let matchesStatus = true; - if (statusFilter === 'reading') { - matchesStatus = book.ReadStatus === 1 || (book.Progress > 0 && book.Progress < 100); - } else if (statusFilter === 'finished') { - matchesStatus = book.ReadStatus === 2 || book.Progress >= 100; - } else if (statusFilter === 'unread') { - matchesStatus = book.ReadStatus === 0 && book.Progress === 0; - } - - return matchesSearch && matchesStatus; - }) - .sort((a, b) => { - if (sortBy === 'recent') { - const dateA = a.DateLastRead ? new Date(a.DateLastRead).getTime() : 0; - const dateB = b.DateLastRead ? new Date(b.DateLastRead).getTime() : 0; - return dateB - dateA; // Newest first - } - if (sortBy === 'title') { - return (a.Title || '').localeCompare(b.Title || ''); - } - if (sortBy === 'progress') { - return b.Progress - a.Progress; // Highest progress first - } - return 0; - }); - }, [books, searchQuery, statusFilter, sortBy]); - - // Group annotations by volume ID - const annotationsByBook = useMemo(() => { - const map = {}; - annotations.forEach((ann) => { - if (!map[ann.VolumeID]) { - map[ann.VolumeID] = []; - } - map[ann.VolumeID].push(ann); - }); - return map; - }, [annotations]); - - const handleCopyAnnotation = (text, id) => { - navigator.clipboard.writeText(text); - setCopiedAnnotationId(id); - setTimeout(() => setCopiedAnnotationId(null), 2000); - }; - - const handleExportAllObsidian = async () => { - try { - const blob = await exportToObsidianZip(books, annotationsByBook); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', `kobo_obsidian_notes_${new Date().toISOString().split('T')[0]}.zip`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - } catch (err) { - console.error('[Export] Obsidian failed:', err); - alert('Failed to export notes: ' + err.message); - } - }; - - const handleExportAllAnki = () => { - try { - const blob = exportToAnkiCsv(annotations); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', `kobo_anki_flashcards_${new Date().toISOString().split('T')[0]}.csv`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - } catch (err) { - console.error('[Export] Anki failed:', err); - alert('Failed to export flashcards: ' + err.message); - } - }; - - const handleExportBookNotes = (book, bookAnns) => { - if (!bookAnns || bookAnns.length === 0) return; - - let markdown = `# Notes: ${book.Title}\n`; - markdown += `By ${book.Author}\n\n`; - markdown += `*Exported from Kobo Backup Manager on ${new Date().toLocaleDateString()}*\n\n---\n\n`; - - bookAnns.forEach((ann, idx) => { - markdown += `### Highlight ${idx + 1}\n`; - if (ann.HighlightedText) markdown += `> ${ann.HighlightedText}\n\n`; - if (ann.Note) markdown += `**Note:** ${ann.Note}\n\n`; - if (ann.DateCreated) markdown += `*Date: ${new Date(ann.DateCreated).toLocaleString()}*\n\n`; - markdown += `---\n\n`; - }); - - const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.setAttribute('download', `${book.Title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}_notes.md`); - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }; - - return ( - <div className="py-8 bg-kobo-cream min-h-screen"> - <Container className="max-w-7xl"> - {/* Dashboard Header */} - <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-8"> - <div> - <h2 className="text-3xl font-display font-bold text-kobo-dark">Library Dashboard</h2> - <p className="text-kobo-gray font-body mt-1"> - Connected:{' '} - <span className="font-semibold text-kobo-dark">{device?.model || 'Kobo Device'}</span>{' '} - (Firmware: {device?.firmwareVersion || 'Unknown'}) - </p> - </div> - <div className="flex gap-3"> - <Button variant="outline" onClick={() => onNavigate('backup')}> - Create Backup - </Button> - </div> - </div> - - {/* Navigation Tabs */} - <div className="flex border-b border-kobo-cream-dark mb-8"> - <button - onClick={() => setActiveTab('books')} - className={`px-6 py-3 font-semibold text-sm transition-all border-b-2 flex items-center gap-2 ${ - activeTab === 'books' - ? 'border-kobo-accent text-kobo-accent' - : 'border-transparent text-kobo-gray hover:text-kobo-dark' - }`} - > - 📚 Books ({books.length}) - </button> - <button - onClick={() => setActiveTab('annotations')} - className={`px-6 py-3 font-semibold text-sm transition-all border-b-2 flex items-center gap-2 ${ - activeTab === 'annotations' - ? 'border-kobo-accent text-kobo-accent' - : 'border-transparent text-kobo-gray hover:text-kobo-dark' - }`} - > - ✍️ Annotations ({annotations.length}) - </button> - <button - onClick={() => setActiveTab('stats')} - className={`px-6 py-3 font-semibold text-sm transition-all border-b-2 flex items-center gap-2 ${ - activeTab === 'stats' - ? 'border-kobo-accent text-kobo-accent' - : 'border-transparent text-kobo-gray hover:text-kobo-dark' - }`} - > - 📊 Statistics - </button> - </div> - - {/* Tab 1: Books */} - {activeTab === 'books' && ( - <div className="space-y-6"> - {/* Filters bar */} - <div className="flex flex-col sm:flex-row gap-4 justify-between bg-white p-4 rounded-xl shadow-xs"> - <div className="flex-1 max-w-md relative"> - <input - type="text" - placeholder="Search books or authors..." - value={searchQuery} - onChange={(e) => setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2 border border-kobo-cream-dark rounded-lg focus:ring-2 focus:ring-kobo-accent/50 focus:border-kobo-accent outline-hidden font-body text-sm" - /> - <span className="absolute left-3 top-2.5 text-kobo-gray">🔍</span> - </div> - <div className="flex flex-wrap gap-3"> - {/* Status Filter */} - <select - value={statusFilter} - onChange={(e) => setStatusFilter(e.target.value)} - className="border border-kobo-cream-dark rounded-lg px-3 py-2 text-sm font-body bg-white outline-hidden focus:ring-2 focus:ring-kobo-accent/50" - > - <option value="all">All Books</option> - <option value="reading">Currently Reading</option> - <option value="finished">Finished</option> - <option value="unread">Unread</option> - </select> - - {/* Sort By */} - <select - value={sortBy} - onChange={(e) => setSortBy(e.target.value)} - className="border border-kobo-cream-dark rounded-lg px-3 py-2 text-sm font-body bg-white outline-hidden focus:ring-2 focus:ring-kobo-accent/50" - > - <option value="recent">Recently Read</option> - <option value="title">Alphabetical</option> - <option value="progress">Reading Progress</option> - </select> - </div> - </div> - - {/* Books Grid */} - {filteredBooks.length === 0 ? ( - <div className="text-center py-12 bg-white rounded-xl shadow-xs border border-kobo-cream-dark"> - <p className="text-kobo-gray font-body text-lg">No books found matching your criteria.</p> - </div> - ) : ( - <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6"> - {filteredBooks.map((book) => { - const bookAnns = annotationsByBook[book.ContentID] || []; - return ( - <button - type="button" - key={book.ContentID} - onClick={() => setSelectedBook(book)} - className="group flex flex-col cursor-pointer text-left focus-visible-ring rounded-md" - > - <CoverPreview - source={source} - coverId={book.CoverId} - title={book.Title} - author={book.Author} - className="mb-3" - /> - <div className="flex-1 min-w-0"> - <h4 className="font-display font-bold text-kobo-dark text-sm leading-tight line-clamp-2 group-hover:text-kobo-accent transition-colors"> - {book.Title} - </h4> - <p className="text-xs font-body text-kobo-gray truncate mt-1">{book.Author}</p> - </div> - - {/* Reading Progress Indicator */} - <div className="mt-2"> - <div className="w-full bg-kobo-cream-dark rounded-full h-[6px] overflow-hidden"> - <div - className={`h-full rounded-full transition-all duration-500 ${ - book.Progress >= 100 ? 'bg-kobo-success' : 'bg-kobo-accent' - }`} - style={{ width: `${book.Progress}%` }} - /> - </div> - <div className="flex justify-between items-center text-[10px] font-semibold font-body text-kobo-gray mt-1"> - <span>{book.Progress}% read</span> - {bookAnns.length > 0 && ( - <span className="bg-kobo-accent/10 text-kobo-accent px-1.5 py-0.5 rounded-md"> - ✍️ {bookAnns.length} - </span> - )} - </div> - </div> - </button> - ); - })} - </div> - )} - </div> - )} - - {/* Tab 2: Annotations */} - {activeTab === 'annotations' && ( - <div className="space-y-6"> - {annotations.length > 0 && ( - <Card className="flex flex-col sm:flex-row justify-between items-center gap-4 bg-linear-to-br from-kobo-accent/5 to-kobo-accent/15 border border-kobo-accent/25"> - <div className="text-left"> - <h4 className="font-display font-bold text-kobo-dark text-lg"> - 🎒 Advanced Notes Exporter - </h4> - <p className="text-sm font-body text-kobo-gray mt-1"> - Export all your Highlights & Notes to Obsidian or Anki format. - </p> - </div> - <div className="flex flex-col sm:flex-row gap-3 w-full sm:w-auto"> - <Button - variant="primary" - size="sm" - onClick={handleExportAllObsidian} - className="flex justify-center items-center gap-1.5" - > - <span>Obsidian (ZIP)</span> - </Button> - <Button - variant="secondary" - size="sm" - onClick={handleExportAllAnki} - className="flex justify-center items-center gap-1.5" - > - <span>Anki (CSV)</span> - </Button> - </div> - </Card> - )} - - {annotations.length === 0 ? ( - <div className="text-center py-16 bg-white rounded-xl shadow-xs border border-kobo-cream-dark"> - <span className="text-4xl block mb-2">✍️</span> - <h3 className="text-xl font-display text-kobo-dark font-semibold">No annotations found</h3> - <p className="text-kobo-gray font-body mt-1"> - Make sure you have highlights or notes in your books. - </p> - </div> - ) : ( - <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> - {books - .filter((b) => (annotationsByBook[b.ContentID] || []).length > 0) - .map((book) => { - const bookAnns = annotationsByBook[book.ContentID] || []; - return ( - <Card key={book.ContentID} className="flex flex-col h-[400px]"> - <div className="flex gap-4 pb-4 border-b border-kobo-cream-dark"> - <div className="w-16 shrink-0"> - <CoverPreview - source={source} - coverId={book.CoverId} - title={book.Title} - author={book.Author} - /> - </div> - <div className="flex-1 min-w-0"> - <h4 className="font-display font-bold text-kobo-dark text-base truncate"> - {book.Title} - </h4> - <p className="text-sm font-body text-kobo-gray truncate">{book.Author}</p> - <span className="inline-block bg-kobo-accent/15 text-kobo-accent text-xs font-semibold px-2 py-0.5 rounded-full mt-2"> - {bookAnns.length} highlights - </span> - </div> - </div> - - {/* Scrolling highlights list */} - <div className="flex-1 overflow-y-auto space-y-4 pt-4 pr-1 font-body text-sm text-kobo-dark"> - {bookAnns.map((ann) => ( - <div - key={ann.BookmarkID} - className="bg-kobo-cream/50 p-3 rounded-lg border border-kobo-cream-dark relative group/ann" - > - <p className="italic text-kobo-dark/95 border-l-2 border-kobo-accent pl-3 mb-2 whitespace-pre-line"> - {ann.HighlightedText} - </p> - {ann.Note && ( - <div className="mt-2 bg-white/70 p-2 rounded-sm border border-kobo-cream-dark/50"> - <span className="text-[10px] font-bold uppercase tracking-wider text-kobo-accent block mb-1"> - My Note - </span> - <p className="text-xs">{ann.Note}</p> - </div> - )} - <div className="flex justify-between items-center text-[10px] text-kobo-gray mt-2"> - <span> - {ann.DateCreated ? new Date(ann.DateCreated).toLocaleDateString() : ''} - </span> - <button - onClick={() => handleCopyAnnotation(ann.HighlightedText, ann.BookmarkID)} - className="opacity-0 group-hover/ann:opacity-100 transition-opacity hover:text-kobo-accent font-semibold flex items-center gap-1" - > - {copiedAnnotationId === ann.BookmarkID ? 'Copied! ✓' : 'Copy'} - </button> - </div> - </div> - ))} - </div> - - <div className="pt-4 border-t border-kobo-cream-dark flex justify-between"> - <Button variant="outline" size="sm" onClick={() => setSelectedBook(book)}> - View Book Details - </Button> - <Button - variant="secondary" - size="sm" - onClick={() => handleExportBookNotes(book, bookAnns)} - > - Export MD - </Button> - </div> - </Card> - ); - })} - </div> - )} - </div> - )} - - {/* Tab 3: Statistics */} - {activeTab === 'stats' && ( - <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> - {/* Summary statistics */} - <Card className="col-span-1 md:col-span-3"> - <h3 className="text-xl font-display font-semibold text-kobo-dark mb-6">Reading Overview</h3> - <div className="grid grid-cols-2 md:grid-cols-4 gap-6 text-center"> - <div className="p-4 bg-kobo-cream rounded-xl"> - <span className="text-3xl block mb-1">🕒</span> - <span className="text-sm text-kobo-gray font-body block mb-1">Time Spent Reading</span> - <span className="text-2xl font-display font-bold text-kobo-dark"> - {stats ? formatDuration(stats.totalMinutesRead) : '0 mins'} - </span> - </div> - <div className="p-4 bg-kobo-cream rounded-xl"> - <span className="text-3xl block mb-1">📚</span> - <span className="text-sm text-kobo-gray font-body block mb-1">Books Added</span> - <span className="text-2xl font-display font-bold text-kobo-dark">{books.length}</span> - </div> - <div className="p-4 bg-kobo-cream rounded-xl"> - <span className="text-3xl block mb-1">✅</span> - <span className="text-sm text-kobo-gray font-body block mb-1">Books Finished</span> - <span className="text-2xl font-display font-bold text-kobo-dark"> - {stats?.booksFinished || 0} - </span> - </div> - <div className="p-4 bg-kobo-cream rounded-xl"> - <span className="text-3xl block mb-1">✍️</span> - <span className="text-sm text-kobo-gray font-body block mb-1">Total Highlights</span> - <span className="text-2xl font-display font-bold text-kobo-dark">{annotations.length}</span> - </div> - </div> - </Card> - - {/* Reading progress breakdown */} - <Card> - <h4 className="text-lg font-display font-semibold text-kobo-dark mb-4">Reading Progress</h4> - <div className="space-y-4 font-body"> - <div> - <div className="flex justify-between text-sm mb-1 text-kobo-gray"> - <span>Finished (100%)</span> - <span className="font-semibold text-kobo-dark"> - {books.filter((b) => b.Progress >= 100).length} books - </span> - </div> - <div className="w-full bg-kobo-cream-dark h-2 rounded-full overflow-hidden"> - <div - className="bg-kobo-success h-full" - style={{ - width: `${ - books.length > 0 - ? (books.filter((b) => b.Progress >= 100).length / books.length) * 100 - : 0 - }%`, - }} - /> - </div> - </div> - - <div> - <div className="flex justify-between text-sm mb-1 text-kobo-gray"> - <span>In Progress (1-99%)</span> - <span className="font-semibold text-kobo-dark"> - {books.filter((b) => b.Progress > 0 && b.Progress < 100).length} books - </span> - </div> - <div className="w-full bg-kobo-cream-dark h-2 rounded-full overflow-hidden"> - <div - className="bg-kobo-accent h-full" - style={{ - width: `${ - books.length > 0 - ? (books.filter((b) => b.Progress > 0 && b.Progress < 100).length / - books.length) * - 100 - : 0 - }%`, - }} - /> - </div> - </div> - - <div> - <div className="flex justify-between text-sm mb-1 text-kobo-gray"> - <span>Unread (0%)</span> - <span className="font-semibold text-kobo-dark"> - {books.filter((b) => b.Progress === 0).length} books - </span> - </div> - <div className="w-full bg-kobo-cream-dark h-2 rounded-full overflow-hidden"> - <div - className="bg-kobo-gray h-full opacity-40" - style={{ - width: `${ - books.length > 0 - ? (books.filter((b) => b.Progress === 0).length / books.length) * 100 - : 0 - }%`, - }} - /> - </div> - </div> - </div> - </Card> - - {/* Reading speeds or time spent details */} - <Card className="col-span-1 md:col-span-2"> - <h4 className="text-lg font-display font-semibold text-kobo-dark mb-4">Top Authors</h4> - <div className="space-y-3 font-body"> - {Object.entries( - books.reduce((acc, book) => { - const author = book.Author || 'Unknown'; - acc[author] = (acc[author] || 0) + 1; - return acc; - }, {}), - ) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([author, count]) => ( - <div - key={author} - className="flex justify-between items-center bg-kobo-cream/50 p-2.5 rounded-lg border border-kobo-cream-dark/50" - > - <span className="font-semibold text-kobo-dark text-sm">{author}</span> - <span className="bg-kobo-accent text-white text-xs font-semibold px-2 py-0.5 rounded-full"> - {count} {count === 1 ? 'book' : 'books'} - </span> - </div> - ))} - </div> - </Card> - </div> - )} - </Container> - - {/* Book details Modal */} - {selectedBook && ( - <div className="fixed inset-0 bg-black/60 backdrop-blur-xs z-50 flex items-center justify-center p-4"> - <div className="bg-white rounded-2xl w-full max-w-3xl max-h-[85vh] overflow-hidden flex flex-col shadow-2xl animate-fade-in"> - {/* Modal Header */} - <div className="flex justify-between items-center p-5 border-b border-kobo-cream-dark bg-kobo-cream/30"> - <h3 className="text-xl font-display font-bold text-kobo-dark truncate max-w-[85%]"> - Book Details - </h3> - <button - onClick={() => setSelectedBook(null)} - className="text-kobo-gray hover:text-kobo-dark p-1.5 rounded-full hover:bg-kobo-cream-dark transition-colors" - > - ✕ - </button> - </div> - - {/* Modal Body (scrollable) */} - <div className="flex-1 overflow-y-auto p-6 font-body"> - <div className="flex flex-col md:flex-row gap-6"> - {/* Book Cover and Progress */} - <div className="w-full md:w-1/3 flex flex-col items-center"> - <div className="w-40 mb-4"> - <CoverPreview - source={source} - coverId={selectedBook.CoverId} - title={selectedBook.Title} - author={selectedBook.Author} - /> - </div> - - {/* Progress bar */} - <div className="w-full text-center"> - <span className="text-2xl font-display font-bold text-kobo-accent"> - {selectedBook.Progress}% - </span> - <span className="text-xs text-kobo-gray block mb-2">Reading Progress</span> - <div className="w-full bg-kobo-cream-dark h-[8px] rounded-full overflow-hidden"> - <div - className="bg-kobo-accent h-full rounded-full" - style={{ width: `${selectedBook.Progress}%` }} - /> - </div> - </div> - </div> - - {/* Metadata details */} - <div className="flex-1 space-y-4"> - <div> - <h2 className="text-2xl font-display font-bold text-kobo-dark leading-tight"> - {selectedBook.Title} - </h2> - <p className="text-lg text-kobo-gray font-medium mt-1">{selectedBook.Author}</p> - </div> - - <div className="grid grid-cols-2 gap-4 text-sm bg-kobo-cream/30 p-4 rounded-xl border border-kobo-cream-dark/55"> - <div> - <span className="text-xs text-kobo-gray block">Time Spent Reading</span> - <span className="font-semibold text-kobo-dark"> - {formatDuration(selectedBook.TimeSpentReading)} - </span> - </div> - <div> - <span className="text-xs text-kobo-gray block">Format / File Type</span> - <span className="font-semibold text-kobo-dark uppercase"> - {selectedBook.MimeType?.split('/').pop() || 'EPUB'} - </span> - </div> - <div> - <span className="text-xs text-kobo-gray block">ISBN</span> - <span className="font-semibold text-kobo-dark">{selectedBook.ISBN || 'N/A'}</span> - </div> - <div> - <span className="text-xs text-kobo-gray block">Publisher</span> - <span className="font-semibold text-kobo-dark truncate"> - {selectedBook.Publisher || 'N/A'} - </span> - </div> - </div> - - {/* Highlights section inside book details modal */} - <div className="pt-2"> - <div className="flex justify-between items-center mb-3"> - <h4 className="text-base font-display font-semibold text-kobo-dark"> - Highlights & Notes ({(annotationsByBook[selectedBook.ContentID] || []).length}) - </h4> - {(annotationsByBook[selectedBook.ContentID] || []).length > 0 && ( - <button - onClick={() => - handleExportBookNotes(selectedBook, annotationsByBook[selectedBook.ContentID]) - } - className="text-xs text-kobo-accent hover:underline font-semibold" - > - Export to MD - </button> - )} - </div> - - {(annotationsByBook[selectedBook.ContentID] || []).length === 0 ? ( - <p className="text-sm text-kobo-gray italic bg-kobo-cream/10 p-3 rounded-lg border border-dashed border-kobo-cream-dark/80"> - No highlights or notes for this book yet. - </p> - ) : ( - <div className="space-y-3 max-h-60 overflow-y-auto pr-1"> - {(annotationsByBook[selectedBook.ContentID] || []).map((ann) => ( - <div - key={ann.BookmarkID} - className="bg-kobo-cream/40 p-3 rounded-lg border border-kobo-cream-dark/40 relative group/modal-ann" - > - <p className="text-xs italic text-kobo-dark/95 border-l-2 border-kobo-accent pl-2 whitespace-pre-line"> - {ann.HighlightedText} - </p> - {ann.Note && ( - <div className="mt-2 bg-white/70 p-2 rounded-sm border border-kobo-cream-dark/50"> - <span className="text-[9px] font-bold uppercase tracking-wider text-kobo-accent block mb-0.5"> - My Note - </span> - <p className="text-xs">{ann.Note}</p> - </div> - )} - <div className="flex justify-between items-center text-[9px] text-kobo-gray mt-1.5"> - <span> - {ann.DateCreated ? new Date(ann.DateCreated).toLocaleDateString() : ''} - </span> - <button - onClick={() => handleCopyAnnotation(ann.HighlightedText, ann.BookmarkID)} - className="opacity-0 group-hover/modal-ann:opacity-100 transition-opacity hover:text-kobo-accent font-semibold" - > - {copiedAnnotationId === ann.BookmarkID ? 'Copied!' : 'Copy'} - </button> - </div> - </div> - ))} - </div> - )} - </div> - </div> - </div> - </div> - - {/* Modal Footer */} - <div className="p-4 border-t border-kobo-cream-dark bg-kobo-cream/20 flex justify-end"> - <Button variant="primary" onClick={() => setSelectedBook(null)}> - Close - </Button> - </div> - </div> - </div> - )} - </div> - ); -} diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx new file mode 100644 index 0000000..e29a5c4 --- /dev/null +++ b/src/pages/NotFound.tsx @@ -0,0 +1,20 @@ +import { useI18n } from '../i18n/core.ts'; +import { Link } from '../router/Router.tsx'; +import { Container } from '../components/layout/Container.tsx'; + +export function NotFound() { + const { m } = useI18n(); + return ( + <Container size="sm" className="py-24 text-center"> + <p className="mb-2 font-display text-6xl text-kobo-accent-ink">404</p> + <h1 className="mb-4 text-4xl text-kobo-dark">{m.notFound.title}</h1> + <p className="mb-8 text-lg text-kobo-gray">{m.notFound.body}</p> + <Link + to="home" + className="rounded-lg bg-kobo-accent px-6 py-3 font-semibold text-kobo-dark hover:bg-kobo-accent-dark focus-visible-ring" + > + {m.notFound.home} + </Link> + </Container> + ); +} diff --git a/src/pages/Privacy.tsx b/src/pages/Privacy.tsx new file mode 100644 index 0000000..b27e7e0 --- /dev/null +++ b/src/pages/Privacy.tsx @@ -0,0 +1,53 @@ +import { useI18n } from '../i18n/core.ts'; +import { REPO_URL } from '../seo/meta.ts'; +import { Container } from '../components/layout/Container.tsx'; + +/** Date of the last change to the privacy policy content. */ +const POLICY_DATE = '2026-09-27'; + +export function Privacy() { + const { m, fmt, formatDate } = useI18n(); + const p = m.privacy; + + return ( + <Container size="sm" className="py-12"> + <article> + <h1 className="mb-2 text-4xl text-kobo-dark sm:text-5xl">{p.title}</h1> + <p className="mb-8 text-sm text-kobo-gray"> + {fmt(p.updated, { date: formatDate(POLICY_DATE, { dateStyle: 'long', timeZone: 'UTC' }) })} + </p> + <p className="mb-10 text-lg text-kobo-dark">{p.intro}</p> + + {p.sections.map((section) => ( + <section key={section.title} className="mb-10" aria-label={section.title}> + <h2 className="mb-3 text-2xl text-kobo-dark">{section.title}</h2> + {section.paragraphs.map((text) => ( + <p key={text} className="mb-3 text-kobo-gray"> + {text} + </p> + ))} + {section.items && ( + <ul className="list-disc space-y-1 pl-6 text-kobo-gray"> + {section.items.map((item) => ( + <li key={item}>{item}</li> + ))} + </ul> + )} + </section> + ))} + + <p className="rounded-lg bg-kobo-cream-dark/60 p-4 text-sm text-kobo-dark"> + GitHub:{' '} + <a + href={REPO_URL} + className="text-kobo-accent-ink underline" + target="_blank" + rel="noopener noreferrer" + > + {REPO_URL.replace('https://', '')} + </a> + </p> + </article> + </Container> + ); +} diff --git a/src/pages/PrivacyPolicy.jsx b/src/pages/PrivacyPolicy.jsx deleted file mode 100644 index 73a37d2..0000000 --- a/src/pages/PrivacyPolicy.jsx +++ /dev/null @@ -1,384 +0,0 @@ -/** - * Privacy Policy page - GDPR compliant (March 2026) - */ - -import { Button } from '../components/common/Button.tsx'; - -export function PrivacyPolicy({ onNavigate }) { - return ( - <div className="max-w-4xl mx-auto px-4 py-12"> - <Button variant="ghost" onClick={() => onNavigate('home')} className="mb-8"> - ← Back to Home - </Button> - - <h1 className="text-4xl font-display font-bold text-kobo-dark mb-4">Privacy Policy</h1> - <p className="text-kobo-gray mb-8"> - <strong>Last Updated:</strong> March 13, 2026 - </p> - - {/* Introduction */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Introduction</h2> - <p className="text-kobo-gray mb-4"> - Kobo Backup Manager is a <strong>free, non-commercial, open-source</strong> web application that - helps Kobo e-reader users backup their libraries. This Privacy Policy explains how data is processed - when you use this tool. - </p> - <p className="text-kobo-gray mb-4"> - <strong>Key principles:</strong> - </p> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-1 mb-4"> - <li>No commercial purpose — this is a free personal project</li> - <li>No account required — no registration or login</li> - <li>Privacy-first architecture — all backup operations happen entirely in your browser</li> - <li>Your Kobo library data never leaves your device</li> - <li>Open source — full code available on GitHub for audit</li> - <li>No cookies — the only analytics used is cookieless</li> - </ul> - </section> - - {/* About This Project */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">About This Project</h2> - <p className="text-kobo-gray mb-4"> - This is a <strong>personal open-source project</strong> with no commercial entity behind it. The - complete source code is available on GitHub, where you can: - </p> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-1 mb-4"> - <li>Review the code to verify privacy claims</li> - <li>Report issues or suggest improvements</li> - <li>Fork and self-host your own copy (without any analytics)</li> - </ul> - <p className="text-kobo-gray mb-4"> - <strong>GDPR note:</strong> Although this is a non-commercial project, we still respect EU - residents' privacy rights. The minimal analytics described below serves only to understand basic - usage; it is never used for commercial gain. - </p> - </section> - - {/* Information We Collect */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Information We Collect</h2> - - <h3 className="text-xl font-display font-bold text-kobo-dark mb-3 mt-6"> - 1. Library Data (Processed Locally Only) - </h3> - <p className="text-kobo-gray mb-4">When you create or restore a backup, the app processes:</p> - <ul className="list-disc list-inside text-kobo-gray ml-4 mb-4 space-y-1"> - <li>Kobo database contents (books, highlights, bookmarks, reading progress)</li> - <li>Book files and metadata</li> - <li>Collections and shelf information</li> - </ul> - <div className="bg-green-50 border-l-4 border-green-500 p-4 mb-4"> - <p className="font-medium text-green-800"> - ✓ This data is processed entirely within your browser using JavaScript and WebAssembly. It is - never transmitted to any server or third party. - </p> - </div> - - <h3 className="text-xl font-display font-bold text-kobo-dark mb-3 mt-6">2. Browser Local Storage</h3> - <p className="text-kobo-gray mb-4">The app stores the following in your browser's local storage:</p> - <ul className="list-disc list-inside text-kobo-gray ml-4 mb-4 space-y-1"> - <li>Backup history (dates, file sizes, statistics)</li> - <li>Application preferences and settings</li> - <li>Analytics banner dismissal flag</li> - </ul> - <p className="text-kobo-gray mb-4"> - This data stays on your device and can be deleted by clearing browser data. - </p> - - <h3 className="text-xl font-display font-bold text-kobo-dark mb-3 mt-6"> - 3. Vercel Analytics (Anonymous Page Views) - </h3> - <p className="text-kobo-gray mb-4"> - The site uses <strong>Vercel Analytics</strong> to collect anonymous, aggregated page-view - statistics. Vercel Analytics is designed with privacy as a core feature: - </p> - <ul className="list-disc list-inside text-kobo-gray ml-4 mb-4 space-y-1"> - <li> - <strong>No cookies</strong> — does not set any cookies - </li> - <li> - <strong>No fingerprinting</strong> — does not track individual users across sessions - </li> - <li> - <strong>No personal data</strong> — IP addresses are not stored; only aggregated counts are kept - </li> - <li>Data collected: page URL, referrer, approximate country, browser/OS family, screen size</li> - </ul> - <div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-4"> - <p className="font-medium text-blue-800"> - Vercel Analytics does not access, collect, or transmit your Kobo library data, backup files, or - any personal reading information. It only counts page visits at an aggregate level. - </p> - </div> - </section> - - {/* How We Use Your Information */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">How We Use Your Information</h2> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-2"> - <li> - <strong>Service Operation:</strong> To provide backup and restore functionality (local only) - </li> - <li> - <strong>Service Improvement:</strong> To understand which features are used most (via aggregate - analytics) - </li> - <li> - <strong>Browser Compatibility:</strong> To ensure the app works across different browsers - </li> - </ul> - </section> - - {/* Legal Basis for Processing (GDPR) */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4"> - Legal Basis for Processing (GDPR) - </h2> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-2"> - <li> - <strong>Legitimate Interests (Art. 6(1)(f)):</strong> Vercel Analytics provides only aggregate, - cookieless statistics that cannot identify individuals. This minimal processing serves our - legitimate interest in understanding how the app is used, and does not override your privacy - rights. - </li> - <li> - <strong>Contract Performance:</strong> Local data processing is necessary to provide the backup - service you request. - </li> - </ul> - <p className="text-kobo-gray mt-4"> - Because Vercel Analytics is cookieless and processes no personal data, it does not require a prior - consent banner under the ePrivacy Directive. - </p> - </section> - - {/* Data Sharing and Third Parties */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4"> - Data Sharing and Third Parties - </h2> - <p className="text-kobo-gray mb-4">The only third-party service this site sends data to is:</p> - - <div className="bg-white p-4 rounded-lg border border-kobo-gray-light"> - <h3 className="font-display font-bold text-kobo-dark mb-2">Vercel Analytics</h3> - <p className="text-kobo-gray text-sm mb-1"> - <strong>Purpose:</strong> Anonymous, aggregate page-view statistics - </p> - <p className="text-kobo-gray text-sm mb-1"> - <strong>Data collected:</strong> Page URL, referrer, approximate country, browser family, screen - size — no personal identifiers, no cookies - </p> - <p className="text-kobo-gray text-sm mb-1"> - <strong>Data processor:</strong> Vercel Inc. (San Francisco, CA, USA) — EU Standard Contractual - Clauses apply for transfers outside the EEA - </p> - <p className="text-kobo-gray text-sm"> - <strong>Privacy Policy:</strong>{' '} - <a - href="https://vercel.com/legal/privacy-policy" - target="_blank" - rel="noopener noreferrer" - className="text-kobo-accent hover:underline" - > - vercel.com/legal/privacy-policy - </a> - </p> - </div> - - <div className="bg-green-50 border-l-4 border-green-500 p-4 mt-4"> - <p className="font-medium text-green-800"> - Your Kobo library data, backup files, and reading information are NEVER shared with any third - party. They are processed only within your browser and never leave your device. - </p> - </div> - </section> - - {/* Data Retention */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Data Retention</h2> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-2"> - <li> - <strong>Local Storage:</strong> Retained until you clear browser data - </li> - <li> - <strong>Vercel Analytics:</strong> Aggregated data retained for up to 1 year per Vercel's policy; - no personal data is stored - </li> - <li> - <strong>Library Data:</strong> Never retained on any server — processed locally only - </li> - </ul> - </section> - - {/* Your Rights (GDPR) */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Your Rights Under GDPR</h2> - <p className="text-kobo-gray mb-4"> - If you are in the European Economic Area (EEA), you have the following rights: - </p> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-2"> - <li> - <strong>Right to Access:</strong> All data is stored locally in your browser — you have full - access - </li> - <li> - <strong>Right to Erasure:</strong> Clear your browser's local storage to delete all app data - </li> - <li> - <strong>Right to Data Portability:</strong> Your backup files are portable ZIP files under your - full control - </li> - <li> - <strong>Right to Object:</strong> You can block Vercel Analytics by using a browser extension that - blocks network requests to{' '} - <code className="text-sm bg-kobo-cream px-1 rounded-sm">va.vercel-scripts.com</code> - </li> - <li> - <strong>Right to Lodge a Complaint:</strong> Contact your local data protection supervisory - authority - </li> - </ul> - - <div className="bg-blue-50 border-l-4 border-blue-500 p-4 mt-4"> - <p className="font-medium text-blue-800 mb-2">✓ Managing Your Data (No Contact Needed):</p> - <ul className="list-disc list-inside text-blue-800 ml-4 space-y-1 text-sm"> - <li> - <strong>Local Storage:</strong> Clear via browser settings (e.g., Chrome → Settings → Privacy → - Clear browsing data) - </li> - <li> - <strong>Analytics:</strong> Block via an ad/tracker blocker, or use browser developer tools to - block <code>va.vercel-scripts.com</code> - </li> - <li> - <strong>Backups:</strong> You control the ZIP files — delete from your computer as needed - </li> - </ul> - </div> - - <p className="text-kobo-gray mt-4"> - Since this is a non-commercial open-source project with no central data controller, there is no - email or support entity to contact for data requests. For questions about the app, please open a - GitHub Issue. - </p> - </section> - - {/* International Data Transfers */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">International Data Transfers</h2> - <p className="text-kobo-gray mb-4"> - Vercel Analytics data may be transferred to and processed in the United States. Vercel Inc. relies - on Standard Contractual Clauses (SCCs) approved by the European Commission for transfers of personal - data from the EEA. - </p> - <p className="text-kobo-gray"> - Because Vercel Analytics collects no personal data (no IP addresses stored, no cookies), the - practical privacy risk of these transfers is negligible. - </p> - </section> - - {/* Cookies */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Cookies</h2> - <p className="text-kobo-gray mb-4"> - This website <strong>does not use any cookies</strong> for tracking or analytics. - </p> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-2"> - <li> - <strong>No tracking cookies</strong> — Vercel Analytics is entirely cookieless - </li> - <li> - <strong>No third-party cookies</strong> — no advertising or retargeting networks - </li> - <li> - <strong>Local Storage only</strong> — app preferences (backup history, settings, banner dismissal) - are stored in browser local storage, which is not a cookie and is not shared with any third party - </li> - </ul> - </section> - - {/* Children's Privacy */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Children's Privacy</h2> - <p className="text-kobo-gray"> - This Service is not directed to individuals under the age of 16. We do not knowingly collect - personal information from children. - </p> - </section> - - {/* Open Source */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Open Source & Transparency</h2> - <p className="text-kobo-gray mb-4"> - The complete source code is available on GitHub. This transparency allows you to: - </p> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-1 mb-4"> - <li>Audit the code to verify every privacy claim in this policy</li> - <li>Self-host your own version — without any analytics at all</li> - <li>Contribute improvements or report issues</li> - </ul> - <div className="bg-kobo-cream p-4 rounded-lg"> - <p className="text-kobo-gray mb-2"> - <strong>GitHub Repository:</strong>{' '} - <a - href="https://github.com/Fanfulla/KoboOfflineBackup" - target="_blank" - rel="noopener noreferrer" - className="text-kobo-accent hover:underline" - > - github.com/Fanfulla/KoboOfflineBackup - </a> - </p> - <p className="text-kobo-gray text-sm"> - All questions, bug reports, and feature requests should be submitted via GitHub Issues. - </p> - </div> - </section> - - {/* Changes to This Policy */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4"> - Changes to This Privacy Policy - </h2> - <p className="text-kobo-gray"> - We may update this Privacy Policy to reflect changes in our practices or legal requirements. The - "Last Updated" date at the top indicates when the policy was last revised. Continued use of the - Service after changes constitutes acceptance of the updated policy. - </p> - </section> - - {/* Questions */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Questions</h2> - <div className="bg-kobo-cream p-4 rounded-lg mb-4"> - <p className="text-kobo-gray mb-2"> - <strong>GitHub Issues:</strong>{' '} - <a - href="https://github.com/Fanfulla/KoboOfflineBackup/issues" - target="_blank" - rel="noopener noreferrer" - className="text-kobo-accent hover:underline" - > - github.com/Fanfulla/KoboOfflineBackup/issues - </a> - </p> - <p className="text-kobo-gray text-sm"> - Open an issue for questions, bug reports, or privacy concerns. - </p> - </div> - <div className="bg-yellow-50 border-l-4 border-yellow-500 p-4"> - <p className="font-medium text-yellow-800"> - Note: This is a personal open-source project with no commercial entity or formal data controller. - There is no support email or helpdesk. Community support is provided via GitHub. - </p> - </div> - </section> - - <div className="text-center mt-12"> - <Button onClick={() => onNavigate('home')}>Return to Home</Button> - </div> - </div> - ); -} diff --git a/src/pages/Restore.jsx b/src/pages/Restore.jsx deleted file mode 100644 index 99aa413..0000000 --- a/src/pages/Restore.jsx +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Restore page - wrapper for the restore wizard - */ - -import { RestoreWizard } from '../components/restore/RestoreWizard.jsx'; - -export function Restore({ onComplete }) { - return <RestoreWizard onComplete={onComplete} />; -} diff --git a/src/pages/Restore.tsx b/src/pages/Restore.tsx new file mode 100644 index 0000000..e8037b1 --- /dev/null +++ b/src/pages/Restore.tsx @@ -0,0 +1,605 @@ +import { useCallback, useEffect, useId, useState, type DragEvent } from 'react'; +import { useI18n } from '../i18n/core.ts'; +import { useRestore } from '../hooks/useRestore.ts'; +import { useFeatureDetection } from '../hooks/useFeatureDetection.ts'; +import { useKoboStore } from '../stores/koboStore.ts'; +import { selectKoboDirectory } from '../utils/fileSystem.ts'; +import { isValidKoboDirectory } from '../utils/validation.ts'; +import { errorCode, errorMessage } from '../utils/errors.ts'; +import { Container } from '../components/layout/Container.tsx'; +import { PageHeader } from '../components/common/PageHeader.tsx'; +import { Stepper } from '../components/common/Stepper.tsx'; +import { Card } from '../components/common/Card.tsx'; +import { Button } from '../components/common/Button.tsx'; +import { Checkbox } from '../components/common/Checkbox.tsx'; +import { Icon } from '../components/common/Icon.tsx'; +import { Modal } from '../components/common/Modal.tsx'; +import { Alert, ErrorAlert } from '../components/common/Alert.tsx'; +import { CircularProgress } from '../components/common/CircularProgress.tsx'; +import type { RestoreMode } from '../utils/restore.ts'; +import type { UiError } from '../types/kobo.ts'; + +type Step = 'file' | 'device' | 'review' | 'run'; +const STEP_INDEX: Record<Step, number> = { file: 0, device: 1, review: 2, run: 3 }; +const LARGE_FILE = 2 * 1024 ** 3; +const PASSWORD_CODES = new Set(['RESTORE_PASSWORD_REQUIRED', 'RESTORE_WRONG_PASSWORD']); + +export function Restore() { + const { m } = useI18n(); + const restore = useRestore(); + const [step, setStep] = useState<Step>('file'); + const toDevice = useCallback(() => setStep('device'), []); + + return ( + <Container className="max-w-5xl py-12"> + <PageHeader title={m.restore.title} /> + <Stepper steps={m.restore.stepLabels} current={STEP_INDEX[step]} /> + {step === 'file' && <FileStep restore={restore} onDone={toDevice} />} + {step === 'device' && ( + <DeviceStep restore={restore} onBack={() => setStep('file')} onDone={() => setStep('review')} /> + )} + {step === 'review' && ( + <ReviewStep restore={restore} onBack={() => setStep('device')} onStart={() => setStep('run')} /> + )} + {step === 'run' && ( + <RunStep + restore={restore} + onRetry={() => { + restore.clearError(); + setStep('review'); + }} + /> + )} + </Container> + ); +} + +type RestoreApi = ReturnType<typeof useRestore>; + +function FileStep({ restore, onDone }: { restore: RestoreApi; onDone: () => void }) { + const { m } = useI18n(); + const inputId = useId(); + const pending = useKoboStore((s) => s.pendingRestore); + const setPending = useKoboStore((s) => s.setPendingRestore); + const [file, setFile] = useState<File | null>(() => pending); + const [password, setPassword] = useState(''); + const [dragging, setDragging] = useState(false); + const needsPassword = !!restore.error && PASSWORD_CODES.has(restore.error.code); + const { parse } = restore; + + const load = async (f: File, pw?: string) => { + setFile(f); + if (await parse(f, pw)) onDone(); + }; + + // Backup handed over from the History page. + useEffect(() => { + if (!pending) return; + setPending(null); + void parse(pending).then((ok) => ok && onDone()); + }, [pending, setPending, parse, onDone]); + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragging(false); + const zip = Array.from(e.dataTransfer.files).find((f) => f.name.toLowerCase().endsWith('.zip')); + if (zip) void load(zip); + }; + + return ( + <Card className="mx-auto max-w-2xl"> + <h2 className="mb-2 text-center text-3xl text-kobo-dark">{m.restore.fileTitle}</h2> + <p className="mb-8 text-center text-kobo-gray">{m.restore.fileSubtitle}</p> + + {needsPassword && file ? ( + <form + className="space-y-4" + onSubmit={(e) => { + e.preventDefault(); + void load(file, password); + }} + > + <Alert tone="info" title={m.restore.passwordTitle}> + {file.name} + </Alert> + <label className="block text-sm font-medium text-kobo-dark"> + {m.restore.passwordLabel} + <input + type="password" + autoComplete="current-password" + value={password} + onChange={(e) => setPassword(e.target.value)} + className="mt-1 w-full rounded-lg border border-kobo-gray-light px-3 py-2 focus:border-kobo-accent focus:outline-hidden focus:ring-2 focus:ring-kobo-accent/50" + // eslint-disable-next-line jsx-a11y-x/no-autofocus -- the password field is the only action on this step + autoFocus + /> + </label> + {restore.error?.code === 'RESTORE_WRONG_PASSWORD' && <ErrorAlert error={restore.error} />} + <Button type="submit" size="lg" className="w-full" loading={restore.busy} disabled={!password}> + <Icon type="lock" /> + {m.restore.unlock} + </Button> + </form> + ) : ( + <> + <div + onDragOver={(e) => { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={onDrop} + className={`rounded-2xl border-4 border-dashed p-10 text-center transition-colors ${ + dragging + ? 'border-kobo-accent bg-kobo-accent/10' + : 'border-kobo-gray-light bg-kobo-cream-dark/30' + }`} + > + <Icon type="upload" size={56} className="mx-auto mb-4 text-kobo-gray" /> + <p className="mb-2 text-xl text-kobo-dark">{m.restore.drop}</p> + <p className="mb-4 text-kobo-gray">{m.restore.or}</p> + <input + id={inputId} + type="file" + accept=".zip,application/zip" + className="sr-only" + onChange={(e) => { + const f = e.target.files?.[0]; + if (f) void load(f); + e.target.value = ''; + }} + /> + <label + htmlFor={inputId} + className="inline-flex cursor-pointer items-center gap-2 rounded-lg border-2 border-kobo-gray-light bg-white px-6 py-3 text-lg font-semibold text-kobo-dark hover:bg-kobo-cream focus-within:ring-2 focus-within:ring-kobo-accent" + > + <Icon type="folder" /> + {m.restore.browse} + </label> + <p className="mt-4 text-sm text-kobo-gray">kobo_backup_*.zip</p> + </div> + + {restore.busy && ( + <p className="mt-6 text-center text-kobo-gray" role="status"> + {m.restore.reading} + </p> + )} + {file && file.size > LARGE_FILE && !restore.error && ( + <Alert tone="warning" className="mt-6"> + {m.restore.largeFile} + </Alert> + )} + {restore.error && ( + <div className="mt-6"> + <ErrorAlert error={restore.error} /> + </div> + )} + </> + )} + </Card> + ); +} + +function DeviceStep({ + restore, + onBack, + onDone, +}: { + restore: RestoreApi; + onBack: () => void; + onDone: () => void; +}) { + const { m } = useI18n(); + const features = useFeatureDetection(); + const [error, setError] = useState<UiError | null>(null); + const [selecting, setSelecting] = useState(false); + + const select = async () => { + setError(null); + setSelecting(true); + try { + const handle = await selectKoboDirectory('readwrite'); + if (!handle) return; + if (!(await isValidKoboDirectory(handle))) { + setError({ title: '', message: 'Not a Kobo device', code: 'INVALID_DEVICE' }); + return; + } + if (await restore.selectTarget(handle)) onDone(); + } catch (err) { + setError({ title: '', message: errorMessage(err), code: errorCode(err, 'FS_PERMISSION_DENIED') }); + } finally { + setSelecting(false); + } + }; + + const shownError = error ?? restore.error; + + return ( + <Card className="mx-auto max-w-3xl text-center"> + <h2 className="mb-2 text-3xl text-kobo-dark">{m.restore.deviceTitle}</h2> + <p className="mb-6 text-kobo-gray">{m.restore.deviceSubtitle}</p> + <Alert tone="warning" className="mb-6"> + {m.restore.deviceWarning} + </Alert> + <ol className="mb-8 space-y-2 text-left text-sm text-kobo-dark"> + {m.connect.steps.map((s) => ( + <li key={s.title} className="flex gap-2"> + <Icon type="check" size="sm" className="mt-0.5 shrink-0 text-kobo-success" /> + <span> + <strong>{s.title}.</strong> {s.body} + </span> + </li> + ))} + </ol> + {shownError && ( + <div className="mb-6"> + <ErrorAlert error={shownError} /> + </div> + )} + {features.fileSystemAccess ? ( + <div className="flex flex-col justify-center gap-3 sm:flex-row-reverse"> + <Button size="lg" onClick={select} loading={selecting || restore.busy}> + <Icon type="folder" /> + {m.restore.selectDevice} + </Button> + <Button size="lg" variant="secondary" onClick={onBack}> + {m.common.back} + </Button> + </div> + ) : ( + <Alert tone="error" title={m.browser.limitedTitle}> + {m.browser.restoreNeedsChromium} + </Alert> + )} + </Card> + ); +} + +function Detail({ label, value }: { label: string; value: string | number }) { + return ( + <div className="flex justify-between gap-4 border-b border-kobo-cream-dark py-1.5 text-sm last:border-0"> + <dt className="text-kobo-gray">{label}</dt> + <dd className="text-right font-medium text-kobo-dark">{value}</dd> + </div> + ); +} + +function ReviewStep({ + restore, + onBack, + onStart, +}: { + restore: RestoreApi; + onBack: () => void; + onStart: () => void; +}) { + const { m, fmt, formatDate, formatDuration, formatNumber } = useI18n(); + const { backup, preview, target, compatibility } = restore; + const canMerge = !!target?.info.hasDatabase; + const fullAllowed = compatibility?.compatible !== false; + + const [mode, setMode] = useState<RestoreMode>(canMerge ? 'merge' : 'full'); + const [merge, setMerge] = useState({ progress: true, annotations: true, collections: true }); + const [includeBooks, setIncludeBooks] = useState(true); + const [includeSettings, setIncludeSettings] = useState(false); + const [cleanExistingBooks, setClean] = useState(false); + const [confirmed, setConfirmed] = useState(false); + + if (!backup || !preview || !target) return null; + const r = m.restore; + + const start = () => { + onStart(); + void restore.restore({ + mode, + merge, + includeBooks, + includeSettings, + cleanExistingBooks: mode === 'full' && cleanExistingBooks, + }); + }; + + const modeOption = (value: RestoreMode, disabled: boolean) => ( + <label + className={`flex cursor-pointer gap-3 rounded-lg border-2 p-4 text-left ${ + mode === value ? 'border-kobo-accent bg-kobo-accent/10' : 'border-kobo-cream-dark' + } ${disabled ? 'cursor-not-allowed opacity-50' : ''}`} + > + <input + type="radio" + name="restore-mode" + value={value} + checked={mode === value} + disabled={disabled} + onChange={() => setMode(value)} + className="mt-1 accent-kobo-accent-ink" + /> + <span> + <span className="flex items-center gap-2 font-semibold text-kobo-dark"> + {r.modes[value].title} + {value === 'merge' && ( + <span className="rounded-full bg-kobo-success/20 px-2 py-0.5 text-xs font-medium"> + {r.modes.merge.badge} + </span> + )} + </span> + <span className="mt-1 block text-sm text-kobo-gray">{r.modes[value].body}</span> + </span> + </label> + ); + + return ( + <div className="space-y-6"> + <h2 className="text-center text-3xl text-kobo-dark">{r.reviewTitle}</h2> + + <div className="grid gap-6 md:grid-cols-2"> + <Card> + <h3 className="mb-3 text-xl text-kobo-dark">{r.backupDetails}</h3> + <dl> + <Detail + label={r.created} + value={formatDate(preview.created, { dateStyle: 'medium', timeStyle: 'short' })} + /> + <Detail label={r.device} value={preview.deviceModel} /> + <Detail label={r.firmware} value={preview.firmwareVersion} /> + <Detail label={r.books} value={formatNumber(preview.statistics.totalBooks)} /> + <Detail label={r.annotations} value={formatNumber(preview.statistics.totalAnnotations)} /> + <Detail label={r.finished} value={formatNumber(preview.statistics.booksFinished)} /> + <Detail label={r.readingTime} value={formatDuration(preview.statistics.totalReadingTime)} /> + {backup.encrypted && <Detail label={r.encrypted} value="AES-256" />} + </dl> + </Card> + <Card> + <h3 className="mb-3 text-xl text-kobo-dark">{r.targetDevice}</h3> + <dl> + <Detail label={r.device} value={target.info.device.model} /> + <Detail label={r.firmware} value={target.info.device.firmwareVersion} /> + </dl> + <div className="mt-4 space-y-2"> + {backup.checksumOk !== null && ( + <Alert tone={backup.checksumOk ? 'success' : 'error'}> + {backup.checksumOk ? r.integrityOk : r.integrityBad} + </Alert> + )} + <h4 className="pt-2 text-base font-semibold text-kobo-dark">{r.compatibility.title}</h4> + {!compatibility || compatibility.warnings.length === 0 ? ( + <Alert tone="success">{r.compatibility.ok}</Alert> + ) : ( + compatibility.warnings.map((w) => ( + <Alert key={w.code} tone={w.code === 'schema-newer' ? 'error' : 'warning'}> + {fmt(r.compatibility[w.code], w.params)} + </Alert> + )) + )} + </div> + </Card> + </div> + + <Card> + <fieldset className="mb-6"> + <legend className="mb-3 text-xl text-kobo-dark">{r.modeTitle}</legend> + <div className="grid gap-3 md:grid-cols-2"> + {modeOption('merge', !canMerge)} + {modeOption('full', !fullAllowed)} + </div> + </fieldset> + + {mode === 'merge' && ( + <fieldset className="mb-6 space-y-3"> + <legend className="mb-2 font-semibold text-kobo-dark">{r.mergeWhat}</legend> + <Checkbox + checked={merge.progress} + onChange={(v) => setMerge({ ...merge, progress: v })} + label={r.mergeProgress} + /> + <Checkbox + checked={merge.annotations} + onChange={(v) => setMerge({ ...merge, annotations: v })} + label={r.mergeAnnotations} + /> + <Checkbox + checked={merge.collections} + onChange={(v) => setMerge({ ...merge, collections: v })} + label={r.mergeCollections} + /> + </fieldset> + )} + + <fieldset className="mb-6 space-y-3"> + <legend className="mb-2 font-semibold text-kobo-dark">{r.filesTitle}</legend> + {backup.bookFiles.length > 0 ? ( + <Checkbox + checked={includeBooks} + onChange={setIncludeBooks} + label={fmt(r.restoreBooks, { count: backup.bookFiles.length })} + sublabel={r.restoreBooksHint} + /> + ) : ( + <Alert tone="info">{r.noBooksInBackup}</Alert> + )} + {backup.extraFiles.length > 0 && ( + <Checkbox + checked={includeSettings} + onChange={setIncludeSettings} + label={fmt(r.restoreSettings, { count: backup.extraFiles.length })} + sublabel={r.restoreSettingsHint} + /> + )} + {mode === 'full' && includeBooks && backup.bookFiles.length > 0 && ( + <Checkbox + checked={cleanExistingBooks} + onChange={setClean} + label={r.cleanFolders} + sublabel={r.cleanFoldersHint} + /> + )} + </fieldset> + + <div className="rounded-lg border-2 border-kobo-error/30 bg-kobo-error/5 p-4"> + <Checkbox checked={confirmed} onChange={setConfirmed} label={<strong>{r.confirm}</strong>} /> + </div> + </Card> + + <div className="flex flex-col justify-center gap-3 sm:flex-row-reverse"> + <Button size="lg" variant="danger" onClick={start} disabled={!confirmed}> + <Icon type="restore" /> + {r.start} + </Button> + <Button size="lg" variant="secondary" onClick={onBack}> + {m.common.back} + </Button> + </div> + </div> + ); +} + +function RunStep({ restore, onRetry }: { restore: RestoreApi; onRetry: () => void }) { + const { m, fmt, plural, formatNumber } = useI18n(); + const r = m.restore; + const [undoOpen, setUndoOpen] = useState(false); + const [undone, setUndone] = useState(false); + const { result, error, progress } = restore; + const canUndo = !undone && (result?.safetySnapshot || (error && restore.target?.info.hasDatabase)); + + const undoModal = ( + <Modal isOpen={undoOpen} onClose={() => setUndoOpen(false)} title={r.undo} closeLabel={m.common.close}> + <p className="mb-6 text-kobo-gray">{r.undoConfirm}</p> + <div className="flex justify-end gap-3"> + <Button variant="secondary" onClick={() => setUndoOpen(false)}> + {m.common.cancel} + </Button> + <Button + variant="danger" + onClick={async () => { + setUndoOpen(false); + if (await restore.undo()) setUndone(true); + }} + > + {r.undo} + </Button> + </div> + </Modal> + ); + + if (error) { + return ( + <Card className="mx-auto max-w-2xl text-center"> + <h2 className="mb-2 text-2xl text-kobo-dark">{r.failedTitle}</h2> + <p className="mb-4 text-kobo-gray">{r.failedBody}</p> + <ErrorAlert error={error} /> + {undone && ( + <Alert tone="success" className="mt-4" live> + {r.undone} + </Alert> + )} + {canUndo && <p className="mt-4 text-sm text-kobo-gray">{r.failedDbWritten}</p>} + <div className="mt-6 flex flex-col justify-center gap-3 sm:flex-row"> + <Button onClick={onRetry}>{m.common.retry}</Button> + {canUndo && ( + <Button variant="secondary" onClick={() => setUndoOpen(true)}> + {r.undo} + </Button> + )} + </div> + {undoModal} + </Card> + ); + } + + if (!result) { + return ( + <Card className="mx-auto max-w-2xl text-center"> + <h2 className="mb-8 text-3xl text-kobo-dark">{r.progressTitle}</h2> + <div className="mb-6 flex justify-center"> + <CircularProgress percent={progress.percent} label={r.progressTitle} /> + </div> + <p className="mb-6 text-lg text-kobo-dark" role="status" aria-live="polite"> + {fmt(r.stages[progress.stage], { current: progress.filesProcessed, total: progress.totalFiles })} + </p> + <Alert tone="error">{r.doNotDisconnect}</Alert> + </Card> + ); + } + + const failures = result.failedBooks; + const verifyFailed = result.verification && !result.verification.ok; + const summary: [string, number][] = [ + [r.summary.books, result.booksRestored], + ...(result.merge + ? ([ + [r.summary.merged, result.merge.booksUpdated], + [r.summary.added, result.merge.booksAdded], + [r.summary.annotations, result.merge.annotationsAdded], + [r.summary.collections, result.merge.collectionsAdded], + ] as [string, number][]) + : []), + ...(result.settingsRestored + ? ([[r.summary.settings, result.settingsRestored]] as [string, number][]) + : []), + ]; + + return ( + <Card className="mx-auto max-w-3xl text-center"> + <div + className={`mx-auto mb-4 flex h-20 w-20 items-center justify-center rounded-full ${failures.length ? 'bg-kobo-warning/25' : 'bg-kobo-success/20'}`} + > + <Icon type={failures.length ? 'warning' : 'check'} size={40} className="text-kobo-dark" /> + </div> + <h2 className="mb-6 text-4xl text-kobo-dark">{failures.length ? r.partialTitle : r.successTitle}</h2> + + <dl className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3"> + {summary.map(([label, value]) => ( + <div key={label} className="rounded-lg border-2 border-kobo-cream-dark p-3"> + <dt className="text-xs text-kobo-gray">{label}</dt> + <dd className="font-display text-2xl text-kobo-dark">{formatNumber(value)}</dd> + </div> + ))} + </dl> + + <div className="mb-6 space-y-3"> + {failures.length > 0 && ( + <Alert tone="warning" title={plural(r.failedFiles, failures.length)}> + <ul className="max-h-40 list-disc overflow-y-auto pl-5"> + {failures.map((f) => ( + <li key={f.originalPath}> + {f.originalPath} — {f.error} + </li> + ))} + </ul> + </Alert> + )} + {verifyFailed && result.verification && ( + <Alert tone="warning"> + {fmt(r.verifyMismatch, { + actual: result.verification.dbBooksCount, + expected: result.verification.expectedCount, + })} + </Alert> + )} + {result.safetySnapshot && !undone && <Alert tone="info">{r.snapshotNote}</Alert>} + {undone && ( + <Alert tone="success" live> + {r.undone} + </Alert> + )} + </div> + + <div className="mb-8 rounded-lg bg-kobo-cream-dark p-6 text-left"> + <h3 className="mb-3 text-xl text-kobo-dark">{r.nextTitle}</h3> + <ol className="list-decimal space-y-1 pl-5 text-kobo-gray"> + {r.next.map((n) => ( + <li key={n}>{n}</li> + ))} + </ol> + </div> + + {canUndo && ( + <Button variant="secondary" onClick={() => setUndoOpen(true)}> + <Icon type="restore" /> + {r.undo} + </Button> + )} + {undoModal} + </Card> + ); +} diff --git a/src/pages/UserGuide.jsx b/src/pages/UserGuide.jsx deleted file mode 100644 index 39ce756..0000000 --- a/src/pages/UserGuide.jsx +++ /dev/null @@ -1,230 +0,0 @@ -/** - * User Guide page - comprehensive instructions for using Kobo Backup Manager - */ - -import { Button } from '../components/common/Button.tsx'; - -export function UserGuide({ onNavigate }) { - return ( - <div className="max-w-4xl mx-auto px-4 py-12"> - <Button variant="ghost" onClick={() => onNavigate('home')} className="mb-8"> - ← Back to Home - </Button> - - <h1 className="text-4xl font-display font-bold text-kobo-dark mb-8">User Guide</h1> - - {/* Getting Started */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Getting Started</h2> - <div className="prose prose-lg"> - <p className="text-kobo-gray mb-4"> - Kobo Backup Manager is a privacy-first web application that helps you backup and restore your Kobo - e-reader library entirely in your browser. No data is sent to any server. - </p> - <div className="bg-kobo-accent/10 border-l-4 border-kobo-accent p-4 mb-4"> - <p className="font-medium text-kobo-dark"> - Requirements: Chrome 86+, Edge 86+, or any Chromium-based browser with File System Access API - support. - </p> - </div> - </div> - </section> - - {/* Creating a Backup */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Creating a Backup</h2> - <div className="space-y-6"> - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Step 1: Connect Your Kobo</h3> - <p className="text-kobo-gray mb-2"> - Connect your Kobo e-reader to your computer using a USB cable. Wait for it to be recognized as a - storage device. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2"> - Step 2: Start Backup Wizard - </h3> - <p className="text-kobo-gray mb-2"> - Click the "Create Backup" button on the home page. The backup wizard will guide you through the - process. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2"> - Step 3: Select Kobo Device Folder - </h3> - <p className="text-kobo-gray mb-2"> - Click "Select Kobo Device" and navigate to your Kobo's root folder. This is typically named - "KOBOeReader" in your file system. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Step 4: Scan Library</h3> - <p className="text-kobo-gray mb-2"> - The app will scan your Kobo's database (KoboReader.sqlite) and display your library statistics: - number of books, highlights, bookmarks, and shelves. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2"> - Step 5: Choose What to Backup - </h3> - <p className="text-kobo-gray mb-2">Select which data you want to include in your backup:</p> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-1"> - <li>Books and documents</li> - <li>Reading progress and statistics</li> - <li>Highlights and annotations</li> - <li>Collections and shelves</li> - </ul> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Step 6: Create Backup</h3> - <p className="text-kobo-gray mb-2"> - Click "Create Backup" and choose where to save the backup ZIP file. The backup will be created - entirely in your browser and downloaded to your chosen location. - </p> - </div> - </div> - </section> - - {/* Restoring a Backup */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Restoring a Backup</h2> - <div className="space-y-6"> - <div className="bg-yellow-50 border-l-4 border-yellow-500 p-4 mb-4"> - <p className="font-medium text-yellow-800"> - ⚠️ Restoring will overwrite existing data on your Kobo device. Make sure to backup your current - library before restoring. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Step 1: Connect Your Kobo</h3> - <p className="text-kobo-gray mb-2"> - Connect the Kobo device where you want to restore the backup using a USB cable. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2"> - Step 2: Start Restore Wizard - </h3> - <p className="text-kobo-gray mb-2">Click the "Restore Backup" button on the home page.</p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Step 3: Select Backup File</h3> - <p className="text-kobo-gray mb-2"> - Choose the backup ZIP file you want to restore from your computer. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2"> - Step 4: Select Target Device - </h3> - <p className="text-kobo-gray mb-2">Choose your Kobo device folder as the restore destination.</p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Step 5: Review and Restore</h3> - <p className="text-kobo-gray mb-2"> - Review what will be restored, then click "Restore Backup" to complete the process. Safely eject - your Kobo device when done. - </p> - </div> - </div> - </section> - - {/* Viewing History */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Viewing Backup History</h2> - <p className="text-kobo-gray mb-4"> - Click "View History" on the home page to see all your previous backups. The history includes: - </p> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-1"> - <li>Backup creation date and time</li> - <li>Number of books, highlights, and bookmarks</li> - <li>Backup file size</li> - <li>Device information</li> - </ul> - <p className="text-kobo-gray mt-4"> - History is stored locally in your browser's storage and never sent to any server. - </p> - </section> - - {/* Troubleshooting */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Troubleshooting</h2> - - <div className="space-y-6"> - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2"> - Can't find KoboReader.sqlite? - </h3> - <p className="text-kobo-gray"> - Make sure your Kobo is connected and recognized as a storage device. Look for a folder named - ".kobo" inside your Kobo device - the database file is located there. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Browser not supported?</h3> - <p className="text-kobo-gray"> - This app requires Chrome 86+, Edge 86+, or another Chromium-based browser. Safari and Firefox do - not yet support the File System Access API needed for direct device access. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Backup takes a long time?</h3> - <p className="text-kobo-gray"> - Large libraries (500+ books) may take a few minutes to backup. This is normal as all processing - happens in your browser without server assistance. - </p> - </div> - - <div> - <h3 className="text-xl font-display font-bold text-kobo-dark mb-2">Restore didn't work?</h3> - <p className="text-kobo-gray"> - Make sure you selected the correct Kobo device folder and that the device is not locked or - write-protected. Try safely ejecting and reconnecting your Kobo. - </p> - </div> - </div> - </section> - - {/* Privacy & Security */} - <section className="mb-12"> - <h2 className="text-2xl font-display font-bold text-kobo-dark mb-4">Privacy & Security</h2> - <p className="text-kobo-gray mb-4"> - Kobo Backup Manager is designed with privacy as a core principle: - </p> - <ul className="list-disc list-inside text-kobo-gray ml-4 space-y-2"> - <li>All processing happens locally in your browser</li> - <li>No data is sent to any server or cloud service</li> - <li>Your backups remain on your computer under your control</li> - <li>No account required, no registration needed</li> - <li>Open source code available for security audit</li> - </ul> - <p className="text-kobo-gray mt-4"> - We use Google Analytics and ContentSquare solely to improve the service. See our{' '} - <button onClick={() => onNavigate('privacy')} className="text-kobo-accent hover:underline"> - Privacy Policy - </button>{' '} - for details. - </p> - </section> - - <div className="text-center mt-12"> - <Button onClick={() => onNavigate('home')}>Return to Home</Button> - </div> - </div> - ); -} diff --git a/src/router/Router.tsx b/src/router/Router.tsx new file mode 100644 index 0000000..82b4f2e --- /dev/null +++ b/src/router/Router.tsx @@ -0,0 +1,77 @@ +import { + useCallback, + useEffect, + useMemo, + useSyncExternalStore, + type AnchorHTMLAttributes, + type MouseEvent, + type ReactNode, +} from 'react'; +import { legacyHashRoute, pathFor, resolvePath, type Lang, type RouteId } from './routes.ts'; +import { NAVIGATE_EVENT, RouterContext, useRouter, type RouterValue } from './context.ts'; + +function subscribe(onChange: () => void) { + window.addEventListener('popstate', onChange); + window.addEventListener(NAVIGATE_EVENT, onChange); + return () => { + window.removeEventListener('popstate', onChange); + window.removeEventListener(NAVIGATE_EVENT, onChange); + }; +} + +const getPath = () => window.location.pathname; + +/** + * Minimal History-API router. `initialPath` is used for server rendering + * (prerender) and as the hydration snapshot. + */ +export function RouterProvider({ children, initialPath }: { children: ReactNode; initialPath?: string }) { + const path = useSyncExternalStore(subscribe, getPath, () => initialPath ?? '/'); + const route = useMemo(() => resolvePath(path), [path]); + + const navigate = useCallback<RouterValue['navigate']>((to, options = {}) => { + if (to === window.location.pathname && !options.replace) return; + window.history[options.replace ? 'replaceState' : 'pushState'](options.state ?? null, '', to); + window.dispatchEvent(new Event(NAVIGATE_EVENT)); + }, []); + + // Redirect legacy hash URLs (e.g. /#faq from the old sitemap). + useEffect(() => { + const legacy = legacyHashRoute(window.location.hash); + if (legacy) navigate(pathFor(legacy, route.lang), { replace: true }); + }, [navigate, route.lang]); + + const value = useMemo(() => ({ path, route, navigate }), [path, route, navigate]); + return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>; +} + +export interface LinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> { + to: RouteId; + lang?: Lang; + children: ReactNode; +} + +/** Real <a href> (crawlable, middle-click friendly) with client-side navigation. */ +export function Link({ to, lang, onClick, children, ...props }: LinkProps) { + const { navigate, route } = useRouter(); + const href = pathFor(to, lang ?? route.lang); + + const handleClick = (e: MouseEvent<HTMLAnchorElement>) => { + onClick?.(e); + if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; + if (props.target && props.target !== '_self') return; + e.preventDefault(); + navigate(href); + }; + + return ( + <a + href={href} + onClick={handleClick} + aria-current={route.id === to && !lang ? 'page' : undefined} + {...props} + > + {children} + </a> + ); +} diff --git a/src/router/context.ts b/src/router/context.ts new file mode 100644 index 0000000..382f3d4 --- /dev/null +++ b/src/router/context.ts @@ -0,0 +1,31 @@ +import { createContext, useCallback, useContext } from 'react'; +import { pathFor, type Lang, type ResolvedRoute, type RouteId } from './routes.ts'; + +export interface RouterValue { + path: string; + route: ResolvedRoute; + navigate: (to: string, options?: { replace?: boolean; state?: unknown }) => void; +} + +export const RouterContext = createContext<RouterValue | null>(null); + +export const NAVIGATE_EVENT = 'kobup:navigate'; + +export function useRouter(): RouterValue { + const value = useContext(RouterContext); + if (!value) throw new Error('useRouter must be used inside <RouterProvider>'); + return value; +} + +export function useLang(): Lang { + return useRouter().route.lang; +} + +/** Navigate to a route in the current language. */ +export function useNavigateTo() { + const { navigate, route } = useRouter(); + return useCallback( + (id: RouteId, lang: Lang = route.lang) => navigate(pathFor(id, lang)), + [navigate, route.lang], + ); +} diff --git a/src/router/routes.test.ts b/src/router/routes.test.ts new file mode 100644 index 0000000..0dcbcb7 --- /dev/null +++ b/src/router/routes.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { legacyHashRoute, normalizePath, pathFor, resolvePath, ROUTE_IDS, LANGS } from './routes.ts'; + +describe('routes', () => { + it('resolves every route in every language (round trip)', () => { + for (const id of ROUTE_IDS) { + for (const lang of LANGS) { + expect(resolvePath(pathFor(id, lang))).toEqual({ id, lang }); + } + } + }); + + it('uses localized Italian slugs', () => { + expect(pathFor('restore', 'it')).toBe('/it/ripristino'); + expect(pathFor('library', 'it')).toBe('/it/libreria'); + }); + + it('normalizes trailing slashes, duplicate slashes and query strings', () => { + expect(normalizePath('/faq/')).toBe('/faq'); + expect(normalizePath('//it//guida?x=1')).toBe('/it/guida'); + expect(resolvePath('/it/')).toEqual({ id: 'home', lang: 'it' }); + }); + + it('returns notFound with the right language', () => { + expect(resolvePath('/nope')).toEqual({ id: 'notFound', lang: 'en' }); + expect(resolvePath('/it/nope')).toEqual({ id: 'notFound', lang: 'it' }); + }); + + it('maps legacy hash URLs from the old sitemap', () => { + expect(legacyHashRoute('#faq')).toBe('faq'); + expect(legacyHashRoute('#dashboard')).toBe('library'); + expect(legacyHashRoute('#unknown')).toBeNull(); + }); +}); diff --git a/src/router/routes.ts b/src/router/routes.ts new file mode 100644 index 0000000..93acd5a --- /dev/null +++ b/src/router/routes.ts @@ -0,0 +1,66 @@ +/** + * Route table. Every page exists in English (no prefix) and Italian (/it/...), + * with localized slugs for better search ranking in each language. + */ + +export const LANGS = ['en', 'it'] as const; +export type Lang = (typeof LANGS)[number]; +export const DEFAULT_LANG: Lang = 'en'; + +export const ROUTES = { + home: { en: '/', it: '/it' }, + backup: { en: '/backup', it: '/it/backup' }, + restore: { en: '/restore', it: '/it/ripristino' }, + library: { en: '/library', it: '/it/libreria' }, + history: { en: '/history', it: '/it/cronologia' }, + guide: { en: '/guide', it: '/it/guida' }, + faq: { en: '/faq', it: '/it/faq' }, + privacy: { en: '/privacy', it: '/it/privacy' }, +} as const satisfies Record<string, Record<Lang, string>>; + +export type RouteId = keyof typeof ROUTES; + +export const ROUTE_IDS = Object.keys(ROUTES) as RouteId[]; + +/** Pages that are pure content and worth indexing. */ +export const INDEXABLE_ROUTES: RouteId[] = [ + 'home', + 'backup', + 'restore', + 'library', + 'guide', + 'faq', + 'privacy', +]; + +export interface ResolvedRoute { + id: RouteId | 'notFound'; + lang: Lang; +} + +export function normalizePath(pathname: string): string { + const clean = pathname.split(/[?#]/)[0]!.replace(/\/{2,}/g, '/'); + return clean.length > 1 ? clean.replace(/\/+$/, '') || '/' : '/'; +} + +export function resolvePath(pathname: string): ResolvedRoute { + const path = normalizePath(pathname); + for (const id of ROUTE_IDS) { + for (const lang of LANGS) { + if (ROUTES[id][lang] === path) return { id, lang }; + } + } + const lang: Lang = path === '/it' || path.startsWith('/it/') ? 'it' : DEFAULT_LANG; + return { id: 'notFound', lang }; +} + +export function pathFor(id: RouteId, lang: Lang): string { + return ROUTES[id][lang]; +} + +/** Old hash-based URLs (kobup.org/#faq) → path routes. */ +export function legacyHashRoute(hash: string): RouteId | null { + const id = hash.replace(/^#\/?/, ''); + if (id === 'dashboard') return 'library'; + return (ROUTE_IDS as string[]).includes(id) ? (id as RouteId) : null; +} diff --git a/src/seo/meta.ts b/src/seo/meta.ts new file mode 100644 index 0000000..3976a83 --- /dev/null +++ b/src/seo/meta.ts @@ -0,0 +1,138 @@ +/** + * Per-page SEO metadata, shared by the client (navigation) and the build-time + * prerenderer (static HTML for crawlers). + */ +import { MESSAGES } from '../i18n/core.ts'; +import { INDEXABLE_ROUTES, LANGS, pathFor, type Lang, type RouteId } from '../router/routes.ts'; + +export const SITE_URL = 'https://www.kobup.org'; +export const OG_IMAGE = `${SITE_URL}/og-image.png`; +export const REPO_URL = 'https://github.com/Fanfulla/KoboOfflineBackup'; + +const OG_LOCALE: Record<Lang, string> = { en: 'en_US', it: 'it_IT' }; + +export interface PageMeta { + title: string; + description: string; + lang: Lang; + canonical: string | null; + alternates: { hreflang: string; href: string }[]; + ogLocale: string; + ogLocaleAlternates: string[]; + robots: string; +} + +export const absoluteUrl = (path: string) => `${SITE_URL}${path === '/' ? '/' : path}`; + +export function pageMeta(id: RouteId | 'notFound', lang: Lang): PageMeta { + const text = MESSAGES[lang].meta.pages[id]; + if (id === 'notFound') { + return { + ...text, + lang, + canonical: null, + alternates: [], + ogLocale: OG_LOCALE[lang], + ogLocaleAlternates: [], + robots: 'noindex', + }; + } + const indexable = INDEXABLE_ROUTES.includes(id); + const alternates: PageMeta['alternates'] = LANGS.map((l) => ({ + hreflang: l, + href: absoluteUrl(pathFor(id, l)), + })); + alternates.push({ hreflang: 'x-default', href: absoluteUrl(pathFor(id, 'en')) }); + return { + ...text, + lang, + canonical: absoluteUrl(pathFor(id, lang)), + alternates, + ogLocale: OG_LOCALE[lang], + ogLocaleAlternates: LANGS.filter((l) => l !== lang).map((l) => OG_LOCALE[l]), + robots: indexable ? 'index, follow, max-image-preview:large' : 'noindex, follow', + }; +} + +/** schema.org JSON-LD blocks for a page. */ +export function structuredData(id: RouteId | 'notFound', lang: Lang): object[] { + if (id === 'notFound') return []; + const m = MESSAGES[lang]; + const url = absoluteUrl(pathFor(id, lang)); + const home = absoluteUrl(pathFor('home', lang)); + const blocks: object[] = []; + + if (id === 'home') { + blocks.push( + { + '@context': 'https://schema.org', + '@type': 'WebApplication', + name: 'KoBup', + alternateName: 'Kobo Backup Manager', + url, + inLanguage: lang, + description: m.meta.pages.home.description, + applicationCategory: 'UtilitiesApplication', + operatingSystem: 'Windows, macOS, Linux, ChromeOS', + browserRequirements: + 'Requires a modern desktop browser. Restore requires Chrome, Edge, Opera or Brave.', + offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' }, + featureList: m.home.features.map((f) => f.title), + screenshot: OG_IMAGE, + softwareVersion: __APP_VERSION__, + license: 'https://opensource.org/licenses/MIT', + isAccessibleForFree: true, + creator: { '@type': 'Person', name: 'Fanfulla' }, + sameAs: [REPO_URL], + }, + { + '@context': 'https://schema.org', + '@type': 'WebSite', + name: 'KoBup', + url: home, + inLanguage: lang, + }, + ); + } else { + blocks.push({ + '@context': 'https://schema.org', + '@type': 'BreadcrumbList', + itemListElement: [ + { '@type': 'ListItem', position: 1, name: 'KoBup', item: home }, + { '@type': 'ListItem', position: 2, name: m.nav[id], item: url }, + ], + }); + } + + if (id === 'faq') { + blocks.push({ + '@context': 'https://schema.org', + '@type': 'FAQPage', + inLanguage: lang, + mainEntity: m.faq.categories.flatMap((c) => + c.items.map((item) => ({ + '@type': 'Question', + name: item.q, + acceptedAnswer: { '@type': 'Answer', text: item.a }, + })), + ), + }); + } + + if (id === 'guide') { + for (const section of m.guide.sections) { + blocks.push({ + '@context': 'https://schema.org', + '@type': 'HowTo', + name: section.title, + inLanguage: lang, + url: `${url}#${section.id}`, + totalTime: 'PT10M', + tool: [{ '@type': 'HowToTool', name: 'USB cable' }], + step: section.steps.map((text, i) => ({ '@type': 'HowToStep', position: i + 1, text })), + }); + } + } + + return blocks; +} diff --git a/src/seo/useDocumentMeta.ts b/src/seo/useDocumentMeta.ts new file mode 100644 index 0000000..f4a8879 --- /dev/null +++ b/src/seo/useDocumentMeta.ts @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import { OG_IMAGE, pageMeta, structuredData } from './meta.ts'; +import type { ResolvedRoute } from '../router/routes.ts'; + +function upsert(selector: string, create: () => HTMLElement, apply: (el: HTMLElement) => void) { + let el = document.head.querySelector<HTMLElement>(selector); + if (!el) { + el = create(); + document.head.appendChild(el); + } + apply(el); +} + +const meta = (attr: 'name' | 'property', key: string, content: string) => + upsert( + `meta[${attr}="${key}"]`, + () => document.createElement('meta'), + (el) => { + el.setAttribute(attr, key); + el.setAttribute('content', content); + }, + ); + +/** Keep <head> in sync with the current route during client-side navigation. */ +export function useDocumentMeta(route: ResolvedRoute) { + useEffect(() => { + const m = pageMeta(route.id, route.lang); + document.documentElement.lang = route.lang; + document.title = m.title; + meta('name', 'description', m.description); + meta('name', 'robots', m.robots); + meta('property', 'og:title', m.title); + meta('property', 'og:description', m.description); + meta('property', 'og:locale', m.ogLocale); + meta('property', 'og:image', OG_IMAGE); + meta('name', 'twitter:title', m.title); + meta('name', 'twitter:description', m.description); + + document.head + .querySelectorAll('link[rel="canonical"], link[rel="alternate"][hreflang]') + .forEach((el) => el.remove()); + if (m.canonical) { + meta('property', 'og:url', m.canonical); + document.head.append( + Object.assign(document.createElement('link'), { rel: 'canonical', href: m.canonical }), + ); + } + for (const alt of m.alternates) { + const link = Object.assign(document.createElement('link'), { rel: 'alternate', href: alt.href }); + link.setAttribute('hreflang', alt.hreflang); + document.head.append(link); + } + + document.head.querySelectorAll('script[data-seo="ld"]').forEach((el) => el.remove()); + for (const block of structuredData(route.id, route.lang)) { + const script = document.createElement('script'); + script.type = 'application/ld+json'; + script.dataset.seo = 'ld'; + script.textContent = JSON.stringify(block); + document.head.append(script); + } + }, [route.id, route.lang]); +} diff --git a/src/stores/koboStore.ts b/src/stores/koboStore.ts index 7ec0f7e..8133a57 100644 --- a/src/stores/koboStore.ts +++ b/src/stores/koboStore.ts @@ -1,73 +1,52 @@ /** - * Global state (Zustand). Only the backup history is persisted. + * Global state (Zustand). + * - session: the connected device and its scan (shared by Backup and Library) + * - history: past backups, persisted to localStorage */ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import type { - BackupHistoryEntry, - DeviceInfo, - KoboAnnotation, - KoboBook, - ReadingStats, -} from '../types/kobo.ts'; +import type { BackupHistoryEntry, ScanResult } from '../types/kobo.ts'; import type { KoboSource } from '../utils/deviceSource.ts'; -export type Page = 'home' | 'dashboard' | 'backup' | 'restore' | 'history' | 'guide' | 'faq' | 'privacy'; - interface KoboState { - device: DeviceInfo | null; - books: KoboBook[]; - annotations: KoboAnnotation[]; - stats: ReadingStats | null; source: KoboSource | null; + scan: ScanResult | null; backups: BackupHistoryEntry[]; - currentPage: Page; + /** A backup file handed from History to the Restore page. */ + pendingRestore: File | null; - setDevice: (device: DeviceInfo | null) => void; - setBooks: (books: KoboBook[]) => void; - setAnnotations: (annotations: KoboAnnotation[]) => void; - setStats: (stats: ReadingStats | null) => void; - setSource: (source: KoboSource | null) => void; - addBackup: (backup: Omit<BackupHistoryEntry, 'id'>) => void; - removeBackup: (id: string) => void; - setCurrentPage: (page: Page) => void; + setDevice: (source: KoboSource, scan: ScanResult) => void; + setPendingRestore: (file: File | null) => void; clearDevice: () => void; + addBackup: (backup: BackupHistoryEntry) => void; + removeBackup: (id: string) => void; } const MAX_HISTORY = 20; +export const newBackupId = () => `backup_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + export const useKoboStore = create<KoboState>()( persist( (set) => ({ - device: null, - books: [], - annotations: [], - stats: null, source: null, + scan: null, backups: [], - currentPage: 'home', + pendingRestore: null, - setDevice: (device) => set({ device }), - setBooks: (books) => set({ books }), - setAnnotations: (annotations) => set({ annotations }), - setStats: (stats) => set({ stats }), - setSource: (source) => set({ source }), - - addBackup: (backup) => - set((state) => ({ - backups: [{ id: `backup_${Date.now()}`, ...backup }, ...state.backups].slice(0, MAX_HISTORY), - })), + setDevice: (source, scan) => set({ source, scan }), + setPendingRestore: (pendingRestore) => set({ pendingRestore }), + clearDevice: () => set({ source: null, scan: null }), + addBackup: (backup) => set((state) => ({ backups: [backup, ...state.backups].slice(0, MAX_HISTORY) })), removeBackup: (id) => set((state) => ({ backups: state.backups.filter((b) => b.id !== id) })), - - setCurrentPage: (page) => set({ currentPage: page }), - - clearDevice: () => set({ device: null, books: [], annotations: [], stats: null, source: null }), }), { name: 'kobo-backup-storage', partialize: (state) => ({ backups: state.backups }), + // Rehydrated on the client after mount (see App) so prerendered HTML matches. + skipHydration: true, }, ), ); diff --git a/src/styles/globals.css b/src/styles/globals.css index 83535c9..05e0bc5 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -5,6 +5,8 @@ --color-kobo-cream-dark: #e8e3d8; --color-kobo-accent: #d4a574; --color-kobo-accent-dark: #b8895e; + /* Accent for text: 5.9:1 on white (WCAG AA), unlike the decorative accent. */ + --color-kobo-accent-ink: #8a5a2b; --color-kobo-dark: #2c2416; --color-kobo-gray: #6b675e; --color-kobo-gray-light: #a8a49a; diff --git a/src/types/kobo.ts b/src/types/kobo.ts index ede6b46..0746790 100644 --- a/src/types/kobo.ts +++ b/src/types/kobo.ts @@ -65,6 +65,7 @@ export interface KoboCollection { Type: string | null; CreationDate: Date | null; LastModified: Date | null; + ContentIds: string[]; } export interface ExtractedData { @@ -147,7 +148,10 @@ export interface ProgressState { totalFiles: number; } -export type ProgressCallback = (stage: string, percent: number, filesProcessed?: number) => void; +export type BackupStage = + 'preparing' | 'books' | 'settings' | 'annotations' | 'metadata' | 'finalizing' | 'verifying' | 'complete'; + +export type ProgressCallback = (stage: BackupStage, percent: number, filesProcessed?: number) => void; export interface UiError { title: string; @@ -163,4 +167,9 @@ export interface BackupHistoryEntry { deviceModel: string; bookCount: number; annotationCount: number; + encrypted?: boolean; + /** The saved file's handle is stored in IndexedDB (verify/restore in one click). */ + hasHandle?: boolean; + /** Result of the read-back verification right after writing. */ + verified?: boolean; } diff --git a/src/utils/backup.ts b/src/utils/backup.ts index 27a428e..be8df2a 100644 --- a/src/utils/backup.ts +++ b/src/utils/backup.ts @@ -13,6 +13,12 @@ import { downloadZip } from 'client-zip'; import { BlobReader, BlobWriter, TextReader, ZipReader, ZipWriter, configure } from '@zip.js/zip.js'; import { downloadBlob } from './fileSystem.ts'; +import { annotationsToMarkdown } from './export.ts'; +import { calculateChecksum } from './checksum.ts'; +import { BOOKS_PREFIX, DEVICE_PREFIX, generateBackupFilename } from './backupInfo.ts'; + +export { calculateChecksum } from './checksum.ts'; +export { BOOKS_PREFIX, DEVICE_PREFIX, estimateBackupSize, generateBackupFilename } from './backupInfo.ts'; import { BackupError, ERROR_CODES } from './errors.ts'; import type { BackupMetadata, @@ -25,10 +31,6 @@ import type { export const APP_VERSION = __APP_VERSION__; -/** ZIP layout: book files keep their device-relative path under these prefixes. */ -export const BOOKS_PREFIX = 'books/'; -export const DEVICE_PREFIX = 'device/'; - export interface BackupVerification { ok: boolean; /** Number of entries found in the written archive. */ @@ -193,7 +195,7 @@ async function* generateZipEntries( const { includeBooks = true, includeAnnotations = true, includeSettings = false } = options; // 1. SQLite database (small, safe to buffer) - onProgress('Preparing backup...', 0); + onProgress('preparing', 0); yield { name: 'KoboReader.sqlite', input: new Blob([koboData.database]), @@ -226,19 +228,19 @@ async function* generateZipEntries( if (includeBooks && koboData.bookFiles?.length > 0) { const total = koboData.bookFiles.length; yield* files(koboData.bookFiles, BOOKS_PREFIX, (i) => - onProgress(`Adding books (${i + 1}/${total})...`, 10 + ((i + 1) / total) * 72, i + 1), + onProgress('books', 10 + ((i + 1) / total) * 72, i + 1), ); } // 2b. Settings, custom fonts, screensavers (stored with their device path) if (includeSettings && koboData.extraFiles?.length > 0) { - onProgress('Adding device settings...', 83); + onProgress('settings', 83); yield* files(koboData.extraFiles, DEVICE_PREFIX); } // 3. Annotations (human-readable export) if (includeAnnotations && koboData.annotations?.length > 0) { - onProgress('Exporting annotations...', 85); + onProgress('annotations', 85); yield { name: 'annotations/all-annotations.md', input: exportAnnotationsAsMarkdown(koboData.annotations), @@ -246,13 +248,13 @@ async function* generateZipEntries( } // 4. Metadata - AFTER all books so the errors array is complete - onProgress('Adding metadata...', 90); + onProgress('metadata', 90); yield { name: 'backup-metadata.json', input: JSON.stringify(metadata, null, 2) }; // 5. README yield { name: 'README.txt', input: generateReadme(metadata) }; - onProgress('Finalizing...', 95); + onProgress('finalizing', 95); } /** @@ -285,14 +287,14 @@ export async function streamBackupToDisk( throw err; } - onProgress('Verifying backup...', 98); + onProgress('verifying', 98); const savedFile = await fileHandle.getFile(); const verification = await verifyBackupArchive(savedFile, { expectedEntries: written, databaseChecksum, password, }); - onProgress('Backup complete', 100); + onProgress('complete', 100); return { filename, size: savedFile.size, metadata, verification }; } catch (error) { console.error('[BACKUP] Streaming failed:', error); @@ -322,7 +324,7 @@ export async function createBackupBlob( databaseChecksum, password, }); - onProgress('Backup complete', 100); + onProgress('complete', 100); return { blob, filename: generateBackupFilename(), size: blob.size, metadata, verification }; } catch (error) { console.error('[BACKUP] Blob creation failed:', error); @@ -338,30 +340,8 @@ export function saveBackup(blob: Blob, filename: string): { filename: string; si } } -/** Suggested filename. Exported so BackupWizard can use it before the save dialog. */ -export function generateBackupFilename(date = new Date()): string { - return `kobo_backup_${date.toISOString().split('T')[0]}.zip`; -} - -export async function calculateChecksum(data: ArrayBuffer | Uint8Array<ArrayBuffer> | Blob): Promise<string> { - try { - const buffer = data instanceof Blob ? await data.arrayBuffer() : data; - const hash = await crypto.subtle.digest('SHA-256', buffer); - return `sha256:${Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('')}`; - } catch { - return 'unavailable'; - } -} - -export function estimateBackupSize(koboData: Pick<ScanResult, 'database' | 'bookFiles'>): number { - let size = koboData.database?.byteLength || 0; - for (const f of koboData.bookFiles ?? []) size += f.size || 0; - return Math.floor((size + 100 * 1024) * 0.9); -} - function exportAnnotationsAsMarkdown(annotations: KoboAnnotation[]): string { - let md = '# Kobo Annotations Export\n\n'; - md += `Exported on: ${new Date().toLocaleString()}\n\nTotal annotations: ${annotations.length}\n\n---\n\n`; + let md = `# Kobo Annotations Export\n\nExported on: ${new Date().toLocaleString()}\n\nTotal annotations: ${annotations.length}\n\n---\n\n`; const byBook = new Map<string, KoboAnnotation[]>(); for (const a of annotations) { const title = a.BookTitle || 'Unknown Book'; @@ -370,13 +350,7 @@ function exportAnnotationsAsMarkdown(annotations: KoboAnnotation[]): string { for (const [title, list] of byBook) { md += `## ${title}\n\n`; if (list[0]?.Author) md += `*by ${list[0].Author}*\n\n`; - list.forEach((a, idx) => { - md += `### Annotation ${idx + 1}\n\n`; - if (a.HighlightedText) md += `> ${a.HighlightedText}\n\n`; - if (a.Note) md += `**Note:** ${a.Note}\n\n`; - if (a.DateCreated) md += `*Created: ${new Date(a.DateCreated).toLocaleString()}*\n\n`; - md += '---\n\n'; - }); + md += annotationsToMarkdown(list, 'Annotation'); } return md; } diff --git a/src/utils/backupInfo.ts b/src/utils/backupInfo.ts new file mode 100644 index 0000000..5c61126 --- /dev/null +++ b/src/utils/backupInfo.ts @@ -0,0 +1,27 @@ +/** + * Lightweight backup helpers (no ZIP libraries), safe to import eagerly. + */ +import type { BackupOptions, ScanResult } from '../types/kobo.ts'; + +/** ZIP layout: files keep their device-relative path under these prefixes. */ +export const BOOKS_PREFIX = 'books/'; +export const DEVICE_PREFIX = 'device/'; + +export function generateBackupFilename(date = new Date()): string { + return `kobo_backup_${date.toISOString().split('T')[0]}.zip`; +} + +/** Uncompressed size of what the backup will contain (books are stored, not compressed). */ +export function estimateBackupSize( + scan: Pick<ScanResult, 'database' | 'bookFiles'> & Partial<Pick<ScanResult, 'extraFiles'>>, + options: Partial<Pick<BackupOptions, 'includeBooks' | 'includeSettings'>> = {}, +): number { + const { includeBooks = true, includeSettings = false } = options; + const sum = (files: { size: number }[] = []) => files.reduce((total, f) => total + (f.size || 0), 0); + return ( + (scan.database?.byteLength || 0) + + (includeBooks ? sum(scan.bookFiles) : 0) + + (includeSettings ? sum(scan.extraFiles) : 0) + + 64 * 1024 // metadata, README, ZIP directory + ); +} diff --git a/src/utils/checksum.ts b/src/utils/checksum.ts new file mode 100644 index 0000000..447bd69 --- /dev/null +++ b/src/utils/checksum.ts @@ -0,0 +1,10 @@ +/** SHA-256 of a buffer/blob as "sha256:<hex>" ("unavailable" if WebCrypto fails). */ +export async function calculateChecksum(data: ArrayBuffer | Uint8Array<ArrayBuffer> | Blob): Promise<string> { + try { + const buffer = data instanceof Blob ? await data.arrayBuffer() : data; + const hash = await crypto.subtle.digest('SHA-256', buffer); + return `sha256:${Array.from(new Uint8Array(hash), (b) => b.toString(16).padStart(2, '0')).join('')}`; + } catch { + return 'unavailable'; + } +} diff --git a/src/utils/fileSystem.ts b/src/utils/fileSystem.ts index c1575e5..5c66f7f 100644 --- a/src/utils/fileSystem.ts +++ b/src/utils/fileSystem.ts @@ -37,8 +37,14 @@ export async function selectDirectory( } } -export function selectKoboDirectory(): Promise<FileSystemDirectoryHandle | null> { - return selectDirectory({ id: 'kobo-device' }); +/** + * Pick the Kobo drive. Restores ask for "readwrite" up front so the browser + * shows a single permission prompt instead of failing midway. + */ +export function selectKoboDirectory( + mode: FileSystemPermissionMode = 'read', +): Promise<FileSystemDirectoryHandle | null> { + return selectDirectory({ id: 'kobo-device', mode }); } /** Read a file handle fully into memory. */ diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts deleted file mode 100644 index 9da75c7..0000000 --- a/src/utils/formatters.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Utility functions for formatting data - */ - -/** Format bytes to a human-readable size (e.g. "1.5 GB"). */ -export function formatBytes(bytes: number | null | undefined, decimals = 2): string { - if (bytes === 0) return '0 Bytes'; - if (!bytes) return 'Unknown'; - - const k = 1024; - const dm = decimals < 0 ? 0 : decimals; - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; - const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); - - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; -} - -/** Format a date to a human-readable string. */ -export function formatDate( - date: string | Date | null | undefined, - options: Intl.DateTimeFormatOptions = {}, - locale = 'en-US', -): string { - if (!date) return 'Never'; - - const dateObj = date instanceof Date ? date : new Date(date); - if (isNaN(dateObj.getTime())) return 'Invalid date'; - - return new Intl.DateTimeFormat(locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - ...options, - }).format(dateObj); -} - -/** Format a progress percentage. */ -export function formatProgress(progress: number | null | undefined): string { - if (progress === null || progress === undefined) return '0%'; - return `${Math.round(Math.min(100, Math.max(0, progress)))}%`; -} - -/** Format a duration in minutes (e.g. "2h 30m"). */ -export function formatDuration(minutes: number | null | undefined): string { - if (!minutes) return '0m'; - - const hours = Math.floor(minutes / 60); - const mins = minutes % 60; - - if (hours === 0) return `${mins}m`; - if (mins === 0) return `${hours}h`; - return `${hours}h ${mins}m`; -} diff --git a/src/utils/handleStore.ts b/src/utils/handleStore.ts new file mode 100644 index 0000000..c9782f2 --- /dev/null +++ b/src/utils/handleStore.ts @@ -0,0 +1,72 @@ +/** + * Persist FileSystemFileHandle objects (structured-cloneable) in IndexedDB so + * a backup saved earlier can be verified or restored in one click. + * Only the handle is stored: no file content ever leaves the user's disk. + */ + +const DB_NAME = 'kobup'; +const STORE = 'backup-handles'; + +function openDb(): Promise<IDBDatabase> { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, 1); + request.onupgradeneeded = () => request.result.createObjectStore(STORE); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function withStore<T>( + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => IDBRequest<T>, +): Promise<T> { + const db = await openDb(); + try { + return await new Promise<T>((resolve, reject) => { + const request = run(db.transaction(STORE, mode).objectStore(STORE)); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + } finally { + db.close(); + } +} + +export const isHandleStoreAvailable = () => typeof indexedDB !== 'undefined'; + +export async function saveBackupHandle(id: string, handle: FileSystemFileHandle): Promise<boolean> { + if (!isHandleStoreAvailable()) return false; + try { + await withStore('readwrite', (store) => store.put(handle, id)); + return true; + } catch { + return false; + } +} + +export async function loadBackupHandle(id: string): Promise<FileSystemFileHandle | null> { + if (!isHandleStoreAvailable()) return null; + try { + return ( + ((await withStore('readonly', (store) => store.get(id))) as FileSystemFileHandle | undefined) ?? null + ); + } catch { + return null; + } +} + +export async function deleteBackupHandle(id: string): Promise<void> { + if (!isHandleStoreAvailable()) return; + await withStore('readwrite', (store) => store.delete(id)).catch(() => {}); +} + +/** Get a readable File from a stored handle, asking for permission if needed. */ +export async function fileFromHandle(handle: FileSystemFileHandle): Promise<File> { + const options = { mode: 'read' } as const; + if ((await handle.queryPermission?.(options)) !== 'granted') { + if ((await handle.requestPermission?.(options)) !== 'granted') { + throw new DOMException('Permission to read the backup file was denied', 'NotAllowedError'); + } + } + return handle.getFile(); +} diff --git a/src/utils/koboDatabase.ts b/src/utils/koboDatabase.ts index 61ea526..81b2c8a 100644 --- a/src/utils/koboDatabase.ts +++ b/src/utils/koboDatabase.ts @@ -224,15 +224,18 @@ export class KoboDatabase { } } + /** User collections (shelves) with the ContentIDs they contain. */ getCollections(): KoboCollection[] { + const notDeleted = `(_IsDeleted IS NULL OR LOWER(_IsDeleted) != 'true')`; try { + const members = new Map<string, string[]>(); + for (const row of this.rows(`SELECT ShelfName, ContentId FROM ShelfContent WHERE ${notDeleted}`)) { + const name = String(row.ShelfName); + members.set(name, [...(members.get(name) ?? []), String(row.ContentId)]); + } return this.rows( - ` - SELECT Id, Name, InternalName, Type, CreationDate, LastModified - FROM Shelf - WHERE Type != 'SystemTag' - ORDER BY Name - `, + `SELECT Id, Name, InternalName, Type, CreationDate, LastModified FROM Shelf + WHERE (Type IS NULL OR Type != 'SystemTag') AND ${notDeleted} ORDER BY Name`, ).map((row) => ({ Id: String(row.Id), Name: String(row.Name), @@ -240,6 +243,7 @@ export class KoboDatabase { Type: str(row.Type), CreationDate: toDate(row.CreationDate), LastModified: toDate(row.LastModified), + ContentIds: members.get(String(row.Name)) ?? [], })); } catch { return []; diff --git a/src/utils/restore.ts b/src/utils/restore.ts index 6e82394..513c8be 100644 --- a/src/utils/restore.ts +++ b/src/utils/restore.ts @@ -15,9 +15,12 @@ import { writeFile, writeFileToPath } from './fileSystem.ts'; import { RestoreError, ERROR_CODES, errorMessage } from './errors.ts'; import { validateBackupMetadata } from './validation.ts'; import { openDatabase } from './koboDatabase.ts'; -import { calculateChecksum, BOOKS_PREFIX, DEVICE_PREFIX } from './backup.ts'; +import { calculateChecksum } from './checksum.ts'; +import { BOOKS_PREFIX, DEVICE_PREFIX } from './backupInfo.ts'; import { isPersonalisationPath } from './scan.ts'; import type { MergeOptions, MergeReport } from './merge.ts'; +import { directorySource } from './deviceSource.ts'; +import { readDeviceVersion } from './koboDevice.ts'; import type { BackupMetadata, BackupOptions, DeviceInfo, ReadingStats } from '../types/kobo.ts'; export interface RestoreBookFile { @@ -82,8 +85,19 @@ export interface RestoreResult { */ export type RestoreMode = 'full' | 'merge'; +export type RestoreStage = + | 'preparing' + | 'snapshot' + | 'cleaning' + | 'merging' + | 'database' + | 'books' + | 'settings' + | 'verifying' + | 'complete'; + export interface RestoreProgress { - stage: string; + stage: RestoreStage; percent: number; filesProcessed?: number; totalFiles?: number; @@ -290,11 +304,11 @@ export async function restoreToDevice( safetySnapshot = true, onProgress, } = options; - const reportProgress = (stage: string, percent: number, details: Partial<RestoreProgress> = {}) => + const reportProgress = (stage: RestoreStage, percent: number, details: Partial<RestoreProgress> = {}) => onProgress?.({ stage, percent, ...details }); try { - reportProgress('Preparing device...', 0); + reportProgress('preparing', 0); let koboFolder: FileSystemDirectoryHandle; try { @@ -322,7 +336,7 @@ export async function restoreToDevice( let snapshotSaved = false; if (safetySnapshot) { - reportProgress('Saving a copy of the current database...', 3); + reportProgress('snapshot', 3); try { snapshotSaved = await snapshotDeviceDatabase(koboFolder); } catch (error) { @@ -337,7 +351,7 @@ export async function restoreToDevice( } if (includeBooks && cleanExistingBooks && backupData.bookFiles.length > 0) { - reportProgress('Removing existing books...', 5); + reportProgress('cleaning', 5); try { await cleanExistingBooksFromDevice(deviceHandle, backupData.bookPathMap); } catch (error) { @@ -345,7 +359,7 @@ export async function restoreToDevice( } } - reportProgress(mode === 'merge' ? 'Merging reading data...' : 'Restoring database...', 15); + reportProgress(mode === 'merge' ? 'merging' : 'database', 15); let mergeReport: MergeReport | null = null; try { let dbData: ArrayBuffer | Uint8Array<ArrayBuffer> = backupData.database; @@ -361,7 +375,7 @@ export async function restoreToDevice( source.close(); } } else { - reportProgress('Sanitizing database...', 18); + reportProgress('database', 18); try { const db = await openDatabase(backupData.database); try { @@ -384,7 +398,7 @@ export async function restoreToDevice( originalError: error, }); } - reportProgress('Database restored', 20); + reportProgress('database', 20); const failedBooks: FailedBook[] = []; let settingsRestored = 0; @@ -441,13 +455,13 @@ export async function restoreToDevice( }); } - reportProgress(`Restoring books (${i + 1}/${totalBooks})...`, 20 + ((i + 1) / totalBooks) * 65, { + reportProgress('books', 20 + ((i + 1) / totalBooks) * 65, { filesProcessed: i + 1, totalFiles: totalBooks, }); } - if (extrasToWrite.length > 0) reportProgress('Restoring device settings...', 86); + if (extrasToWrite.length > 0) reportProgress('settings', 86); for (const extra of extrasToWrite) { const zipEntry = entries.get(extra.path); if (!zipEntry || !isPersonalisationPath(extra.originalPath)) continue; @@ -465,11 +479,11 @@ export async function restoreToDevice( } finally { await zipReader.close(); } - reportProgress('Files restored', 88); + reportProgress('books', 88); } // Post-restore validation: reopen the database from the device and count books - reportProgress('Verifying restore...', 90); + reportProgress('verifying', 90); let verification: RestoreVerification | null = null; try { const restored = await (await (await koboFolder.getFileHandle(DB_NAME)).getFile()).arrayBuffer(); @@ -486,7 +500,7 @@ export async function restoreToDevice( console.warn('[RESTORE] Post-restore verification failed:', verifyError); } - reportProgress('Restore complete', 100); + reportProgress('complete', 100); return { success: true, @@ -726,3 +740,46 @@ async function cleanExistingBooksFromDevice( for (const filename of filesToClean) await remove(filename, false); return cleanedCount; } + +export interface RestoreTargetInfo { + device: DeviceInfo; + /** The device already has a database (merge is possible). */ + hasDatabase: boolean; + /** A pre-restore safety copy exists (undo is possible). */ + hasSnapshot: boolean; +} + +/** Identify the Kobo that will receive the backup (for compatibility checks). */ +export async function inspectRestoreTarget( + deviceHandle: FileSystemDirectoryHandle, +): Promise<RestoreTargetInfo> { + const source = directorySource(deviceHandle); + const [version, dbFile, hasSnapshot] = await Promise.all([ + readDeviceVersion(source), + source.getFile(`.kobo/${DB_NAME}`), + hasSafetySnapshot(deviceHandle), + ]); + let schemaVersion = 0; + let databaseVersion = 'Unknown'; + if (dbFile && dbFile.size > 0) { + try { + const db = await openDatabase(await dbFile.arrayBuffer()); + schemaVersion = db.getSchemaVersion(); + databaseVersion = db.getDatabaseVersion(); + db.close(); + } catch (error) { + console.warn('[RESTORE] Could not read the target database:', error); + } + } + return { + device: { + model: version?.model ?? 'Unknown', + modelId: version?.modelId, + firmwareVersion: version?.firmwareVersion ?? 'Unknown', + databaseVersion, + schemaVersion, + }, + hasDatabase: !!dbFile && dbFile.size > 0, + hasSnapshot, + }; +} diff --git a/vite.config.ts b/vite.config.ts index b781078..2260eca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,10 +19,8 @@ export default defineConfig({ rolldownOptions: { output: { codeSplitting: { - groups: [ - { name: 'vendor', test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/ }, - { name: 'zip', test: /node_modules[\\/](client-zip|@zip\.js)[\\/]/ }, - ], + // ZIP/SQLite code is split automatically into lazy chunks (dynamic imports). + groups: [{ name: 'vendor', test: /node_modules[\\/](react|react-dom|scheduler)[\\/]/ }], }, }, }, From 4da04838ac3793ee4c14e9f218110d7033efe447 Mon Sep 17 00:00:00 2001 From: Fanfulla <224395883+Fanfulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:07:14 +0200 Subject: [PATCH 7/8] feat: prerendered SEO pages, installable offline PWA, streaming downloads SEO - every route is prerendered at build time in English and Italian (17 static pages) with localized <title>, description, canonical, hreflang (en/it/x-default), Open Graph/Twitter tags and JSON-LD (WebApplication, WebSite, BreadcrumbList, FAQPage, HowTo); pages hydrate without mismatches - real sitemap.xml with hreflang alternates (replaces the #hash URLs), robots.txt pointing to it, 404.html served with a real 404 status - correct <html lang> per page; Italian home at /it PWA / browsers - web app manifest and generated PNG icons (standard, maskable, Apple) - service worker precaches pages and hashed assets (works offline) and streams backups as downloads in Firefox/Safari (no showSaveFilePicker), pulling one chunk at a time so memory stays flat for multi-GB libraries Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- index.html | 98 +++-------------------------- package.json | 3 +- public/apple-touch-icon.png | Bin 0 -> 1103 bytes public/icon-192.png | Bin 0 -> 1236 bytes public/icon-512.png | Bin 0 -> 4064 bytes public/icon-maskable-512.png | Bin 0 -> 3390 bytes public/manifest.webmanifest | 19 ++++++ public/robots.txt | 26 +------- public/sitemap.xml | 50 --------------- scripts/make-icons.ts | 84 +++++++++++++++++++++++++ scripts/postbuild.ts | 97 ++++++++++++++++++++++++++++ src/entry-server.tsx | 54 ++++++++++++++++ src/hooks/useBackup.ts | 13 +++- src/hydration.test.tsx | 46 ++++++++++++++ src/main.tsx | 27 ++++++-- src/seo/head.ts | 46 ++++++++++++++ src/sw/protocol.test.ts | 80 +++++++++++++++++++++++ src/sw/protocol.ts | 119 +++++++++++++++++++++++++++++++++++ src/sw/sw.ts | 116 ++++++++++++++++++++++++++++++++++ src/utils/backup.ts | 42 +++++++++++++ src/utils/verify.test.ts | 30 ++++++++- vercel.json | 4 ++ 22 files changed, 784 insertions(+), 170 deletions(-) create mode 100644 public/apple-touch-icon.png create mode 100644 public/icon-192.png create mode 100644 public/icon-512.png create mode 100644 public/icon-maskable-512.png create mode 100644 public/manifest.webmanifest delete mode 100644 public/sitemap.xml create mode 100644 scripts/make-icons.ts create mode 100644 scripts/postbuild.ts create mode 100644 src/entry-server.tsx create mode 100644 src/hydration.test.tsx create mode 100644 src/seo/head.ts create mode 100644 src/sw/protocol.test.ts create mode 100644 src/sw/protocol.ts create mode 100644 src/sw/sw.ts diff --git a/index.html b/index.html index 892dbf3..d5dd258 100644 --- a/index.html +++ b/index.html @@ -3,101 +3,21 @@ <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - - <!-- ===================================================================== - PRIMARY SEO - ====================================================================== --> - <title>KoBup — Free Kobo Backup Tool | Backup Your Kobo Library Online + + KoBup — Free Kobo Backup & Restore Tool, 100% in Your Browser - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + -
+
diff --git a/package.json b/package.json index cf29a43..59a9bd8 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ }, "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/postbuild.ts", "preview": "vite preview", + "icons": "node scripts/make-icons.ts", "typecheck": "tsc -b", "lint": "eslint . --max-warnings 0", "format": "prettier --write .", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..6838f8a1eca51cd240df4951388127df312ea23f GIT binary patch literal 1103 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWiw)6U=j0laSW-r_4ayrU#Po4!^6!% zMY9y!eIMlHBp2{)V1M4=3#8^hI=Sgj!ppYeS0&-~s(<}6-o1VG&*AWwH~)GktTNfj z!l=8I>(|S@(u$#(RaNh+JVKeG9K69WJLN)seU)<@*Us(_>XPf?ge!N;Slrw`-7Yx@-zWEa`!EJ-k8lbJ-6-sOmoZsbLKz& z`|tLL1=HtD+b8_uoc@y5mmhN+)Z;z;FB`q+F=yCy<;C0V z6Ypvy|F4OLc!T+^<6C~9cbND7U`}mXR}jB)f&4wYjyF-Wm!7jf{!jVtYZ+hu{Q2E) zKIxU*{d|7o{@?f4={c@1eCD-4d;jGt${Y)R-;;mg_kZfCKR)lxEnb+`x4-%OJ$!+% z{*tx-pMI}Cmgo5Y$F-d&@-E+enjST)_IrNm^xE(7o0d6#xBC{)`sL<{ygBS!>&pOLCwrt@|=Iy>7UPU>_0VEwaHHS=GM}er{~u9{dM1cyH=Zf zSL^5Xj8#+2HJGbxqV=$|*+7oO)aLM7+FGnHIwP4QiQ zpT9(1{L-y2b$$!PcTI>}E`Gs#FZ+d~$Np(I{QGz9<%zs08|(M!0%Hyoecu**V+TiL zRmz3?d+V$=NvFPjqQrRjdI^N-nrPfV1Mn~kd*7U z))cb8E1h|&ab45pXc1Q4*!}g4VNA6~kTjd-SpWIeNkmHD##XBY$~I98_SZdiLCsR} zduwdqX&RKJLRh{`*O%o7ruo%UFOp#yD7E3=qoZ)%(*`@Kps5XEx)I m8JKf_y?HhfVtv$G%U{2)e@fkXp%Pe7FnGH9xvXh)97}5L?i~x-mLb>;}joim)we=-M9=W(-%u z3T{IL6t(~ZWRzAhKo=vO3`@X)T$Yj!7%GR9y9M?G8`H%1$#=Zp_a0Ba@3|4iIbmT+ zF$Dk%HY<>eu8NI8G)8CcP!a(EU&Ib%esXnSvM;Xo%yAOZ^^iM0ck7wM?|sOV@9z`b z+W03uQv5TGP2R+j=5&*TWgPZ!Xq$V_*V+8fD4kU5kooAvCx`9MPB573cjJ3EwIkWJV47BRzKXqt@9Q#HA_anRVhWjE_v0Awy6Bwxfpzz5G#3y61{@LYKfxzs8SSQ;ae3Xh z5}MMLc#sLKEhGTFDb2I|7h2v_l!@~NAcDyzoUM=&b<<96FOY!y1>6j1rvR1_Y^G6q zxn7re0v_3d#6(dMR=jX3M-+5Dd3hu!q-@sGr^YWrkK>O9BpIREO-E*;Fpy7Vbh_)V z5ESX57W^RlEF8h!UHrHVCnmSeuD;rHH=L~Gv|r)6!~2X2OH++mT~*o9j-x)6WPljL z<-L&wy86lTuN*yb@1*2omKJ)<-ik#(QtPkk1-B_1B=@HJ0Fs^j5m6(MR;k?dYqZnf z{?R&BE#K26evA~%3e{_nEXY7HKUq5pN2U^MAUH$(^d)) znc)sOxcu9iw_CxKx{OB;vL`r+GJ$Ru^|XELWlrLTgx ztVZ=9K76o3zKou5#^g>3N1=JWFbWeRXea}z;Wl*WWoQ*-|HZ{RgL;h)`Pjeb{g-}S z4NV_|7U9GhX;n<3;C$8(tNQ1HL5c;AUs%5j=(6HDye4Uz*#BI_I}{PFw{t#?we@ix zW zR(FzPsWl=qv<#p9N}!}+I;37eIat|~z0wRec*eB5ev!aOS~D>=yLO{d_YNbj$tAh( zYrjo}h0&;Mp8fZa{i)NvmGR*!rR=-z;y=x|qqa5()2`Dp0%jiTe!dgleCm(0eQ8BM z8H-0tn?J^aWlEGs4(68mrnsGHNN0%;c~jOqVFO$Ta6HyNFx%gNE*l!(LOYJ{WnCs! zwibn!c-wJhsF1NLXg-gF8K*x@>qA}bQQ0(+s+??tpHo$wYH_6s_!+A za&@H4)+}N`OJGh|QL)}a(qA)s#oA%q2#a;0F`G{HOW9Z6T}W0wa(U5(D~qyP3svod U_)AA~-8LSBeVh|mdyJp)4^4yR*Z=?k literal 0 HcmV?d00001 diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..509de3c89290497febbbe9ae0af6adebabfbd7d5 GIT binary patch literal 4064 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 PuY%-t>k|*hD_{CADHvnw literal 0 HcmV?d00001 diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..666e896 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,19 @@ +{ + "id": "/", + "name": "KoBup — Kobo Backup & Restore", + "short_name": "KoBup", + "description": "Back up and restore your Kobo e-reader: books, highlights and reading progress. Runs locally, works offline.", + "lang": "en", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#f5f1e8", + "theme_color": "#d4a574", + "categories": ["books", "utilities", "productivity"], + "icons": [ + { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/public/robots.txt b/public/robots.txt index 06d34b2..86ce923 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,27 +1,5 @@ User-agent: * Allow: / -Sitemap: https://www.kobup.org/sitemap.xml - -User-agent: Googlebot -Allow: / - -User-agent: Bingbot -Allow: / - -User-agent: GPTBot -Allow: / - -User-agent: Google-Extended -Allow: / +Disallow: /__kobup_download__/ -User-agent: anthropic-ai -Allow: / - -User-agent: ChatGPT-User -Allow: / - -User-agent: PerplexityBot -Allow: / - -User-agent: Claude-Web -Allow: / +Sitemap: https://www.kobup.org/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index 711153b..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,50 +0,0 @@ - - - - - - https://www.kobup.org/ - 2026-03-15 - monthly - 1.0 - - - - - - https://www.kobup.org/#backup - 2026-03-15 - monthly - 0.9 - - - - https://www.kobup.org/#restore - 2026-03-15 - monthly - 0.9 - - - - https://www.kobup.org/#guide - 2026-03-15 - monthly - 0.7 - - - - https://www.kobup.org/#faq - 2026-03-15 - monthly - 0.7 - - - - https://www.kobup.org/#privacy - 2026-03-15 - yearly - 0.4 - - - diff --git a/scripts/make-icons.ts b/scripts/make-icons.ts new file mode 100644 index 0000000..6e161c7 --- /dev/null +++ b/scripts/make-icons.ts @@ -0,0 +1,84 @@ +/** + * Generates the PWA / touch icons (PNG) from code, without image libraries: + * a rounded accent square with an open-book glyph, matching favicon.svg. + * + * node scripts/make-icons.ts + */ +import { writeFileSync } from 'node:fs'; +import { deflateSync, crc32 } from 'node:zlib'; + +type RGBA = [number, number, number, number]; + +const ACCENT_TOP: RGBA = [212, 165, 116, 255]; // #D4A574 +const ACCENT_BOTTOM: RGBA = [184, 137, 94, 255]; // #B8895E +const WHITE: RGBA = [255, 255, 255, 255]; + +function chunk(type: string, data: Buffer): Buffer { + const head = Buffer.alloc(8); + head.writeUInt32BE(data.length, 0); + head.write(type, 4, 'ascii'); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])) >>> 0, 0); + return Buffer.concat([head, data, crc]); +} + +function png(size: number, pixel: (x: number, y: number) => RGBA): Buffer { + const raw = Buffer.alloc((size * 4 + 1) * size); + for (let y = 0; y < size; y++) { + raw[y * (size * 4 + 1)] = 0; // filter: none + for (let x = 0; x < size; x++) { + const [r, g, b, a] = pixel(x, y); + const o = y * (size * 4 + 1) + 1 + x * 4; + raw[o] = r; + raw[o + 1] = g; + raw[o + 2] = b; + raw[o + 3] = a; + } + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(size, 0); + ihdr.writeUInt32BE(size, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // RGBA + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +const mix = (a: RGBA, b: RGBA, t: number): RGBA => a.map((v, i) => Math.round(v + (b[i]! - v) * t)) as RGBA; + +/** Icon in unit coordinates (0..1). `maskable` keeps the glyph inside the safe zone. */ +function icon(size: number, { rounded, maskable }: { rounded: boolean; maskable: boolean }) { + const radius = rounded ? 0.2 : 0; + const scale = maskable ? 0.6 : 0.75; + return png(size, (px, py) => { + const x = (px + 0.5) / size; + const y = (py + 0.5) / size; + // Rounded square mask + const dx = Math.max(radius - x, 0, x - (1 - radius)); + const dy = Math.max(radius - y, 0, y - (1 - radius)); + if (radius && dx * dx + dy * dy > radius * radius) return [0, 0, 0, 0]; + + // Open book: two pages with a spine, in glyph space u,v ∈ [-1, 1] + const u = (x - 0.5) / (scale / 2); + const v = (y - 0.5) / (scale / 2); + const inBook = Math.abs(u) <= 0.9 && v >= -0.55 + Math.abs(u) * 0.12 && v <= 0.6 + Math.abs(u) * 0.08; + const spine = Math.abs(u) < 0.05; + const line = + Math.abs(u) > 0.2 && + Math.abs(u) < 0.75 && + [-0.2, 0.05, 0.3].some((l) => Math.abs(v - l - Math.abs(u) * 0.1) < 0.035); + if (inBook && !spine && !line) return WHITE; + return mix(ACCENT_TOP, ACCENT_BOTTOM, y); + }); +} + +const out = new URL('../public/', import.meta.url); +writeFileSync(new URL('icon-192.png', out), icon(192, { rounded: true, maskable: false })); +writeFileSync(new URL('icon-512.png', out), icon(512, { rounded: true, maskable: false })); +writeFileSync(new URL('icon-maskable-512.png', out), icon(512, { rounded: false, maskable: true })); +writeFileSync(new URL('apple-touch-icon.png', out), icon(180, { rounded: false, maskable: false })); +console.log('Icons written to public/'); diff --git a/scripts/postbuild.ts b/scripts/postbuild.ts new file mode 100644 index 0000000..b0e01ae --- /dev/null +++ b/scripts/postbuild.ts @@ -0,0 +1,97 @@ +/** + * Runs after `vite build`: + * 1. SSR-builds src/entry-server.tsx and prerenders every route (EN + IT) + * into static HTML with per-page (SEO, social previews, fast LCP) + * 2. writes sitemap.xml + * 3. builds the service worker with the list of files to precache (offline) + * + * node scripts/postbuild.ts + */ +import { createHash } from 'node:crypto'; +import { mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { build } from 'vite'; + +const root = fileURLToPath(new URL('..', import.meta.url)); +const dist = join(root, 'dist'); +const ssrOut = join(root, 'node_modules', '.cache', 'kobup-ssr'); + +// 1. Server bundle ----------------------------------------------------------- +await build({ + root, + logLevel: 'warn', + build: { + ssr: 'src/entry-server.tsx', + outDir: ssrOut, + emptyOutDir: true, + copyPublicDir: false, + minify: false, + }, +}); +interface ServerEntry { + render(path: string): { html: string; head: string; lang: string }; + pages(): [path: string, file: string][]; + sitemap(lastmod: string): string; +} +const server = (await import(pathToFileURL(join(ssrOut, 'entry-server.js')).href)) as ServerEntry; + +// 2. Prerender --------------------------------------------------------------- +const template = readFileSync(join(dist, 'index.html'), 'utf8'); +if (!template.includes('') || !template.includes('')) { + throw new Error('dist/index.html is missing the / placeholders'); +} + +for (const [path, file] of server.pages()) { + const { html, head, lang } = server.render(path); + const page = template + .replace('', ``) + .replace(/[\s\S]*?/, head) + .replace('
', `
${html}
`); + const target = join(dist, file); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, page); +} +console.log(`Prerendered ${server.pages().length} pages`); + +writeFileSync(join(dist, 'sitemap.xml'), server.sitemap(new Date().toISOString().split('T')[0]!)); +rmSync(ssrOut, { recursive: true, force: true }); + +// 3. Service worker ---------------------------------------------------------- +const walk = (dir: string): string[] => + readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + return statSync(full).isDirectory() ? walk(full) : [full]; + }); + +const SKIP = /(^|\/)(sw\.js|404\.html|sitemap\.xml|robots\.txt|og-image\.(png|svg))$|\.(woff|map)$/; +const files = walk(dist) + .map((full) => relative(dist, full).split(sep).join('/')) + .filter((file) => !SKIP.test(file)) + .sort(); + +/** Pages are cached under their clean URL (what the browser navigates to). */ +const toUrl = (file: string) => + file === 'index.html' ? '/' : `/${file.replace(/\/index\.html$/, '').replace(/\.html$/, '')}`; +const precache = files.map(toUrl); + +const version = createHash('sha256'); +for (const file of files) version.update(file).update(readFileSync(join(dist, file))); + +await build({ + root, + configFile: false, + logLevel: 'warn', + define: { + __PRECACHE__: JSON.stringify(precache), + __SW_VERSION__: JSON.stringify(version.digest('hex').slice(0, 12)), + }, + build: { + outDir: dist, + emptyOutDir: false, + copyPublicDir: false, + target: 'es2022', + lib: { entry: 'src/sw/sw.ts', formats: ['iife'], name: 'kobupServiceWorker', fileName: () => 'sw.js' }, + }, +}); +console.log(`Service worker: ${precache.length} files precached`); diff --git a/src/entry-server.tsx b/src/entry-server.tsx new file mode 100644 index 0000000..91360ca --- /dev/null +++ b/src/entry-server.tsx @@ -0,0 +1,54 @@ +/** + * Server entry used at build time (scripts/postbuild.ts) to prerender every + * route into static HTML for search engines, social previews and fast first paint. + */ +import { StrictMode } from 'react'; +import { renderToString } from 'react-dom/server'; +import App from './App.tsx'; +import { renderHead } from './seo/head.ts'; +import { INDEXABLE_ROUTES, LANGS, ROUTE_IDS, pathFor, resolvePath } from './router/routes.ts'; +import { absoluteUrl } from './seo/meta.ts'; + +export function render(path: string) { + const route = resolvePath(path); + return { + lang: route.lang, + head: renderHead(route.id, route.lang), + html: renderToString( + + + , + ), + }; +} + +/** Every page to prerender: [url path, output file relative to dist]. */ +export function pages(): [string, string][] { + const out: [string, string][] = []; + for (const id of ROUTE_IDS) { + for (const lang of LANGS) { + const path = pathFor(id, lang); + // "/it" → it.html (served at /it by Vercel cleanUrls and vite preview alike) + out.push([path, path === '/' ? 'index.html' : `${path.slice(1)}.html`]); + } + } + out.push(['/404', '404.html']); + return out; +} + +/** sitemap.xml with hreflang alternates for every indexable page. */ +export function sitemap(lastmod: string): string { + const urls = INDEXABLE_ROUTES.flatMap((id) => + LANGS.map((lang) => { + const alternates = [ + ...LANGS.map( + (l) => ` `, + ), + ` `, + ].join('\n'); + const priority = id === 'home' ? '1.0' : ['backup', 'restore'].includes(id) ? '0.9' : '0.7'; + return ` \n ${absoluteUrl(pathFor(id, lang))}\n ${lastmod}\n ${priority}\n${alternates}\n `; + }), + ); + return `\n\n${urls.join('\n')}\n\n`; +} diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts index 25d4a0f..0a024be 100644 --- a/src/hooks/useBackup.ts +++ b/src/hooks/useBackup.ts @@ -3,11 +3,13 @@ * * - Chrome/Edge: the wizard calls showSaveFilePicker() first (user gesture) * and passes the handle here → streamed to disk, no memory limit. + * - Firefox/Safari with the service worker active: streamed as a download. * - Otherwise: the archive is built in memory and downloaded. */ import { useCallback, useState } from 'react'; import type { BackupResult } from '../utils/backup.ts'; import { errorCode, errorMessage } from '../utils/errors.ts'; +import { canStreamDownload, streamDownload } from '../sw/protocol.ts'; import type { BackupOptions, BackupStage, ScanResult, UiError } from '../types/kobo.ts'; export interface CreateBackupOptions extends Partial { @@ -51,17 +53,26 @@ export function useBackup() { try { const backup = await import('../utils/backup.ts'); let outcome: BackupResult; + const streamed = !!writableFileHandle; if (writableFileHandle) { outcome = await backup.streamBackupToDisk(scan, writableFileHandle, suggestedFilename, { ...runOptions, onProgress, }); + } else if (canStreamDownload()) { + // Firefox/Safari: stream through the service worker, no memory limit. + const { stream, metadata, getSize } = await backup.createBackupStream(scan, { + ...runOptions, + onProgress, + }); + await streamDownload(stream, suggestedFilename); + outcome = { filename: suggestedFilename, size: getSize(), metadata, verification: null }; } else { const { blob, ...rest } = await backup.createBackupBlob(scan, { ...runOptions, onProgress }); backup.saveBackup(blob, suggestedFilename); outcome = { ...rest, filename: suggestedFilename }; } - const final = { ...outcome, duration: Date.now() - startTime, streamed: !!writableFileHandle }; + const final = { ...outcome, duration: Date.now() - startTime, streamed }; setResult(final); return final; } catch (err) { diff --git a/src/hydration.test.tsx b/src/hydration.test.tsx new file mode 100644 index 0000000..9b0dda3 --- /dev/null +++ b/src/hydration.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +/** + * The prerendered HTML must hydrate without mismatches, otherwise React + * throws the server markup away (slower first paint, lost SEO benefit). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { StrictMode, act } from 'react'; +import { hydrateRoot, type Root } from 'react-dom/client'; +import { render } from './entry-server.tsx'; +import App from './App.tsx'; +import { LANGS, ROUTE_IDS, pathFor } from './router/routes.ts'; + +let root: Root | null = null; +afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ''; +}); + +describe('hydration of prerendered pages', () => { + for (const lang of LANGS) { + for (const id of ROUTE_IDS) { + const path = pathFor(id, lang); + it(`hydrates ${path} without mismatches`, async () => { + const { html } = render(path); + window.history.replaceState(null, '', path); + document.body.innerHTML = `
${html}
`; + const container = document.getElementById('root')!; + const errors: unknown[] = []; + + await act(async () => { + root = hydrateRoot( + container, + + + , + { onRecoverableError: (error) => errors.push(error) }, + ); + }); + + expect(errors).toEqual([]); + expect(container.querySelector('h1')).not.toBeNull(); + }); + } + } +}); diff --git a/src/main.tsx b/src/main.tsx index ed8614a..a5eba4c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,16 +1,35 @@ import { StrictMode } from 'react'; import { createRoot, hydrateRoot } from 'react-dom/client'; import App from './App.tsx'; +import { resolvePath } from './router/routes.ts'; import './styles/fonts.ts'; import './styles/globals.css'; const container = document.getElementById('root')!; +const path = window.location.pathname; const app = ( - + ); -// Pages are prerendered at build time: hydrate them, render from scratch otherwise (dev). -if (container.hasChildNodes()) hydrateRoot(container, app); -else createRoot(container).render(app); +// Hydrate the prerendered page when it was rendered for this route (the +// shared 404.html is English-only: /it/unknown renders from scratch instead). +const prerendered = container.dataset.path; +const sameRoute = + prerendered !== undefined && JSON.stringify(resolvePath(prerendered)) === JSON.stringify(resolvePath(path)); + +if (container.firstElementChild && sameRoute) { + hydrateRoot(container, app); +} else { + container.textContent = ''; + createRoot(container).render(app); +} + +if (import.meta.env.PROD && 'serviceWorker' in navigator) { + window.addEventListener('load', () => { + navigator.serviceWorker + .register('/sw.js') + .catch((error) => console.warn('[SW] registration failed:', error)); + }); +} diff --git a/src/seo/head.ts b/src/seo/head.ts new file mode 100644 index 0000000..cdf59f0 --- /dev/null +++ b/src/seo/head.ts @@ -0,0 +1,46 @@ +/** + * Static markup for a page (used by the build-time prerenderer). + * Attribute names mirror what useDocumentMeta updates on the client. + */ +import { OG_IMAGE, pageMeta, structuredData } from './meta.ts'; +import type { Lang, RouteId } from '../router/routes.ts'; + +const escapeHtml = (value: string) => + value.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + +/** JSON safe to inline in " or HTML comment breakouts. */ +const inlineJson = (data: unknown) => + JSON.stringify(data).replace(//g, '\\u003e').replace(/&/g, '\\u0026'); + +export function renderHead(id: RouteId | 'notFound', lang: Lang): string { + const m = pageMeta(id, lang); + const meta = (attr: 'name' | 'property', key: string, content: string) => + ``; + + const tags = [ + `${escapeHtml(m.title)}`, + meta('name', 'description', m.description), + meta('name', 'robots', m.robots), + ...(m.canonical ? [``] : []), + ...m.alternates.map((a) => ``), + meta('property', 'og:type', 'website'), + meta('property', 'og:site_name', 'KoBup'), + meta('property', 'og:title', m.title), + meta('property', 'og:description', m.description), + ...(m.canonical ? [meta('property', 'og:url', m.canonical)] : []), + meta('property', 'og:locale', m.ogLocale), + ...m.ogLocaleAlternates.map((l) => meta('property', 'og:locale:alternate', l)), + meta('property', 'og:image', OG_IMAGE), + meta('property', 'og:image:width', '1200'), + meta('property', 'og:image:height', '630'), + meta('property', 'og:image:alt', m.title), + meta('name', 'twitter:card', 'summary_large_image'), + meta('name', 'twitter:title', m.title), + meta('name', 'twitter:description', m.description), + meta('name', 'twitter:image', OG_IMAGE), + ...structuredData(id, lang).map( + (block) => ``, + ), + ]; + return tags.join('\n '); +} diff --git a/src/sw/protocol.test.ts b/src/sw/protocol.test.ts new file mode 100644 index 0000000..84bd8eb --- /dev/null +++ b/src/sw/protocol.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest'; +import { serveStream, type PageToWorker } from './protocol.ts'; + +/** Plays the service worker's role: pull until done, collecting the bytes. */ +function pullAll(port: MessagePort): Promise { + const chunks: Uint8Array[] = []; + return new Promise((resolve, reject) => { + port.onmessage = (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'chunk') { + chunks.push(new Uint8Array(msg.chunk)); + port.postMessage({ type: 'pull' }); + } else if (msg.type === 'done') resolve(chunks); + else reject(new Error(msg.message)); + }; + port.postMessage({ type: 'registered' }); + port.postMessage({ type: 'pull' }); + }); +} + +const streamOf = (...parts: string[]) => + new ReadableStream({ + start(controller) { + for (const p of parts) controller.enqueue(new TextEncoder().encode(p)); + controller.close(); + }, + }); + +describe('service-worker download protocol', () => { + it('sends every chunk in order, then done', async () => { + const { port1, port2 } = new MessageChannel(); + const { registered, done } = serveStream(streamOf('PK', 'zip-', 'data'), port1); + const received = pullAll(port2); + await registered; + await done; + const text = (await received).map((c) => new TextDecoder().decode(c)).join(''); + expect(text).toBe('PKzip-data'); + port2.close(); + }); + + it('only reads from the source when the worker pulls (back-pressure)', async () => { + let reads = 0; + const source = new ReadableStream( + { + pull(controller) { + reads++; + controller.enqueue(new Uint8Array([reads])); + }, + }, + { highWaterMark: 0 }, + ); + const { port1, port2 } = new MessageChannel(); + serveStream(source, port1); + await new Promise((r) => setTimeout(r, 20)); + expect(reads).toBe(0); + port2.postMessage({ type: 'pull' }); + await new Promise((r) => setTimeout(r, 20)); + expect(reads).toBe(1); + port1.close(); + port2.close(); + }); + + it('propagates source errors and cancellation', async () => { + const failing = new ReadableStream({ + pull(controller) { + controller.error(new Error('disk unplugged')); + }, + }); + const a = new MessageChannel(); + const failed = serveStream(failing, a.port1); + await expect(pullAll(a.port2)).rejects.toThrow('disk unplugged'); + await expect(failed.done).rejects.toThrow('disk unplugged'); + + const b = new MessageChannel(); + const cancelled = serveStream(streamOf('x'), b.port1); + b.port2.postMessage({ type: 'cancel' }); + await expect(cancelled.done).rejects.toMatchObject({ name: 'AbortError' }); + b.port2.close(); + }); +}); diff --git a/src/sw/protocol.ts b/src/sw/protocol.ts new file mode 100644 index 0000000..57dfccb --- /dev/null +++ b/src/sw/protocol.ts @@ -0,0 +1,119 @@ +/** + * Streaming downloads through the service worker (Firefox/Safari have no + * showSaveFilePicker). The page registers a download on a MessagePort; the + * worker answers a request to DOWNLOAD_PREFIX + id with a ReadableStream that + * pulls chunks from the page one at a time, so memory stays flat no matter how + * large the backup is. + */ + +export const DOWNLOAD_PREFIX = '/__kobup_download__/'; + +/** page → worker (global postMessage) */ +export type WorkerMessage = + | { type: 'kobup-download-register'; id: string; filename: string; mime: string } + | { type: 'kobup-keepalive' }; + +/** worker → page (on the port) */ +export type WorkerToPage = { type: 'registered' } | { type: 'pull' } | { type: 'cancel' }; + +/** page → worker (on the port) */ +export type PageToWorker = + { type: 'chunk'; chunk: ArrayBuffer } | { type: 'done' } | { type: 'error'; message: string }; + +/** + * Serve `stream` over `port`, one chunk per "pull". `registered` resolves when + * the worker acknowledges the download; `done` when the stream is fully sent. + */ +export function serveStream(stream: ReadableStream, port: MessagePort) { + const reader = stream.getReader(); + let resolveRegistered!: () => void; + let resolveDone!: () => void; + let rejectDone!: (reason: unknown) => void; + const registered = new Promise((resolve) => (resolveRegistered = resolve)); + const done = new Promise((resolve, reject) => { + resolveDone = resolve; + rejectDone = reject; + }); + // Callers await `registered` first; don't report `done` as unhandled meanwhile. + done.catch(() => {}); + const send = (msg: PageToWorker, transfer: Transferable[] = []) => port.postMessage(msg, transfer); + + port.onmessage = async (event: MessageEvent) => { + switch (event.data.type) { + case 'registered': + resolveRegistered(); + break; + case 'pull': + try { + const { value, done: finished } = await reader.read(); + if (finished) { + send({ type: 'done' }); + port.close(); + resolveDone(); + } else { + // Copy into an ArrayBuffer we own so it can be transferred (zero-copy). + const copy = value.slice(); + send({ type: 'chunk', chunk: copy.buffer }, [copy.buffer]); + } + } catch (error) { + send({ type: 'error', message: error instanceof Error ? error.message : String(error) }); + port.close(); + rejectDone(error); + } + break; + case 'cancel': + void reader.cancel().catch(() => {}); + port.close(); + rejectDone(new DOMException('The download was cancelled', 'AbortError')); + break; + } + }; + + return { registered, done }; +} + +/** Whether this page is controlled by our service worker (streaming possible). */ +export function canStreamDownload(): boolean { + return ( + typeof navigator !== 'undefined' && + !!navigator.serviceWorker?.controller && + typeof ReadableStream !== 'undefined' + ); +} + +/** Download `stream` as a file without buffering it in memory. */ +export async function streamDownload( + stream: ReadableStream, + filename: string, + mime = 'application/zip', +) { + const worker = navigator.serviceWorker.controller; + if (!worker) throw new Error('Service worker not active'); + + const id = crypto.randomUUID(); + const { port1, port2 } = new MessageChannel(); + const { registered, done } = serveStream(stream, port1); + worker.postMessage({ type: 'kobup-download-register', id, filename, mime } satisfies WorkerMessage, [ + port2, + ]); + await registered; + + const iframe = document.createElement('iframe'); + iframe.hidden = true; + iframe.title = filename; + iframe.src = `${DOWNLOAD_PREFIX}${id}`; + document.body.append(iframe); + + // Messages keep the worker alive while the browser is writing the file. + const keepAlive = setInterval( + () => + navigator.serviceWorker.controller?.postMessage({ type: 'kobup-keepalive' } satisfies WorkerMessage), + 10_000, + ); + try { + await done; + } finally { + clearInterval(keepAlive); + setTimeout(() => iframe.remove(), 30_000); + } +} diff --git a/src/sw/sw.ts b/src/sw/sw.ts new file mode 100644 index 0000000..08c4fee --- /dev/null +++ b/src/sw/sw.ts @@ -0,0 +1,116 @@ +/// +/** + * KoBup service worker: + * - offline: precaches the prerendered pages and hashed assets + * - streaming downloads for browsers without showSaveFilePicker + * + * Built by scripts/postbuild.ts, which injects the precache list and version. + */ +import { DOWNLOAD_PREFIX, type PageToWorker, type WorkerMessage, type WorkerToPage } from './protocol.ts'; + +declare const self: ServiceWorkerGlobalScope; +declare const __PRECACHE__: string[]; +declare const __SW_VERSION__: string; + +const CACHE = `kobup-${__SW_VERSION__}`; +const downloads = new Map(); + +self.addEventListener('install', (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(__PRECACHE__))); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + for (const key of await caches.keys()) + if (key.startsWith('kobup-') && key !== CACHE) await caches.delete(key); + await self.clients.claim(); + })(), + ); +}); + +self.addEventListener('message', (event) => { + const data = event.data as WorkerMessage; + if (data?.type === 'kobup-download-register' && event.ports[0]) { + const port = event.ports[0]; + downloads.set(data.id, { port, filename: data.filename, mime: data.mime }); + port.postMessage({ type: 'registered' } satisfies WorkerToPage); + } + // 'kobup-keepalive': receiving the event is enough to extend the worker's lifetime. +}); + +function downloadResponse(id: string): Response { + const download = downloads.get(id); + if (!download) return new Response('Download expired', { status: 404 }); + downloads.delete(id); + const { port, filename, mime } = download; + + const stream = new ReadableStream( + { + pull: (controller) => + new Promise((resolve) => { + port.onmessage = (event: MessageEvent) => { + const msg = event.data; + if (msg.type === 'chunk') controller.enqueue(new Uint8Array(msg.chunk)); + else if (msg.type === 'done') controller.close(); + else controller.error(new Error(msg.message)); + resolve(); + }; + port.postMessage({ type: 'pull' } satisfies WorkerToPage); + }), + cancel: () => port.postMessage({ type: 'cancel' } satisfies WorkerToPage), + }, + { highWaterMark: 1 }, + ); + + const ascii = filename.replace(/[^\x20-\x7e]/g, '_').replace(/"/g, ''); + return new Response(stream, { + headers: { + 'Content-Type': mime, + 'Content-Disposition': `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`, + 'X-Content-Type-Options': 'nosniff', + 'Content-Security-Policy': "default-src 'none'", + }, + }); +} + +async function networkFirst(request: Request): Promise { + const cache = await caches.open(CACHE); + try { + const response = await fetch(request); + if (response.ok) void cache.put(request, response.clone()); + return response; + } catch { + const url = new URL(request.url); + const path = url.pathname.replace(/\/+$/, '') || '/'; + return ( + (await cache.match(path)) ?? + (await cache.match(path.startsWith('/it') ? '/it' : '/')) ?? + new Response('Offline', { status: 503, headers: { 'Content-Type': 'text/plain' } }) + ); + } +} + +async function cacheFirst(request: Request): Promise { + const cache = await caches.open(CACHE); + const cached = await cache.match(request); + if (cached) return cached; + const response = await fetch(request); + if (response.ok) void cache.put(request, response.clone()); + return response; +} + +self.addEventListener('fetch', (event) => { + const { request } = event; + const url = new URL(request.url); + if (url.origin !== self.location.origin || request.method !== 'GET') return; + + if (url.pathname.startsWith(DOWNLOAD_PREFIX)) { + event.respondWith(downloadResponse(url.pathname.slice(DOWNLOAD_PREFIX.length))); + return; + } + if (url.pathname.startsWith('/_vercel/')) return; // analytics: network only + + if (request.mode === 'navigate') event.respondWith(networkFirst(request)); + else event.respondWith(cacheFirst(request)); +}); diff --git a/src/utils/backup.ts b/src/utils/backup.ts index be8df2a..8cb92a3 100644 --- a/src/utils/backup.ts +++ b/src/utils/backup.ts @@ -332,6 +332,48 @@ export async function createBackupBlob( } } +/** + * STREAM PATH (Firefox/Safari): the archive as a ReadableStream, handed to the + * service worker as a download. Nothing is buffered; `getSize()` is final once + * the stream has been fully read. + */ +export async function createBackupStream( + koboData: ScanResult, + options: BackupRunOptions = {}, +): Promise<{ stream: ReadableStream; metadata: BackupMetadata; getSize: () => number }> { + const { onProgress = () => {}, password } = options; + const metadata = buildMetadata(koboData, options, await calculateChecksum(koboData.database), []); + const entries = generateZipEntries(koboData, options, onProgress, metadata); + + let source: ReadableStream; + if (password) { + const { readable, writable } = new TransformStream(); + const writer = writable.getWriter(); + // Our own sink so a failure can abort the stream even while zip.js holds its lock. + const sink = new WritableStream({ + write: (chunk) => writer.write(chunk), + close: () => writer.close(), + abort: (reason) => writer.abort(reason), + }); + writeEncryptedZip(entries, sink, password).catch((err) => writer.abort(err).catch(() => {})); + source = readable; + } else { + source = downloadZip(entries).body!; + } + + let size = 0; + const counted = source.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + size += chunk.byteLength; + controller.enqueue(chunk); + }, + flush: () => onProgress('complete', 100), + }), + ); + return { stream: counted, metadata, getSize: () => size }; +} + export function saveBackup(blob: Blob, filename: string): { filename: string; size: number } { try { return downloadBlob(blob, filename); diff --git a/src/utils/verify.test.ts b/src/utils/verify.test.ts index 850e44b..5764b74 100644 --- a/src/utils/verify.test.ts +++ b/src/utils/verify.test.ts @@ -7,7 +7,8 @@ vi.mock('sql.js', async (importOriginal) => (await import('../test/sqlJsNode.ts')).sqlJsNodeMock(importOriginal as never), ); -const { createBackupBlob, verifyBackupArchive, calculateChecksum } = await import('./backup.ts'); +const { createBackupBlob, createBackupStream, verifyBackupArchive, calculateChecksum } = + await import('./backup.ts'); const { parseBackupFile } = await import('./restore.ts'); async function scan(): Promise { @@ -74,6 +75,33 @@ describe('backup verification', () => { }); }); +describe('streamed backups (service-worker download path)', () => { + it.each([undefined, 'hunter22'])('produce a valid archive (password: %s)', async (password) => { + let lastStage = ''; + const { stream, metadata, getSize } = await createBackupStream(await scan(), { + includeSettings: true, + password, + onProgress: (stage) => (lastStage = stage), + }); + const blob = await new Response(stream).blob(); + expect(getSize()).toBe(blob.size); + expect(lastStage).toBe('complete'); + expect(metadata.options?.encrypted).toBe(!!password); + const parsed = await parseBackupFile(blob, password); + expect(parsed.bookFiles.map((f) => f.originalPath)).toEqual(['A/b.epub']); + expect(parsed.extraFiles.map((f) => f.originalPath)).toEqual(['.kobo/Kobo/Kobo eReader.conf']); + expect(parsed.checksumOk).toBe(true); + }); + + it('records unreadable files instead of failing (encrypted stream)', async () => { + const data = await scan(); + data.bookFiles[0]!.getFile = () => Promise.reject(new Error('unplugged')); + const { stream, metadata } = await createBackupStream(data, { password: 'hunter22' }); + await new Response(stream).blob(); + expect(metadata.integrity?.errors).toEqual([{ file: 'A/b.epub', error: 'unplugged' }]); + }); +}); + describe('restore-side integrity check', () => { it('flags a backup whose database does not match the recorded checksum', async () => { const dbBytes = await buildKoboDb(); diff --git a/vercel.json b/vercel.json index 7ee0eec..7227b6a 100644 --- a/vercel.json +++ b/vercel.json @@ -22,6 +22,10 @@ { "source": "/assets/(.*)", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + }, + { + "source": "/sw.js", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] } ] } From edaaf9d706f8dd2a481c80f38e0a767e61e02062 Mon Sep 17 00:00:00 2001 From: Fanfulla <224395883+Fanfulla@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:11:17 +0200 Subject: [PATCH 8/8] docs: v2.0.0 README, AGENTS.md; remove leftover dead code - README rewritten for the current app: features, browser support table, backup contents/layout, privacy, stack, structure, dev commands, changelog - AGENTS.md with verification commands, architecture map and project rules - version 2.0.0 (routes and UI changed); Node 22.18+ for TS scripts - removed exports left unused by the rewrite and the unused jest-dom package Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- AGENTS.md | 36 +++++ README.md | 294 +++++++++++++++------------------------- package-lock.json | 170 ++++++++++------------- package.json | 5 +- src/router/context.ts | 4 - src/types/kobo.ts | 7 - src/utils/export.ts | 2 - src/utils/fileSystem.ts | 80 ----------- src/utils/scan.ts | 2 - src/utils/validation.ts | 10 -- 10 files changed, 214 insertions(+), 396 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..99288d0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# AGENTS.md — KoBup + +Privacy-first React/Vite web app that backs up and restores Kobo e-readers entirely in the browser. +Live: https://www.kobup.org (Vercel). Repo: https://github.com/Fanfulla/KoboOfflineBackup + +## Commands + +- `npm run dev` — dev server (no prerender, no service worker) +- `npm run check` — **run before every commit**: `tsc -b` + ESLint (0 warnings) + Vitest + production build +- `npm run format` / `npm run format:check` — Prettier (CI runs `format:check`) +- `npm run build` — `vite build` + `node scripts/postbuild.ts` (prerender 17 pages, sitemap.xml, sw.js) +- `npm run preview` — serve `dist/` (check `/it`, `/it/ripristino`, `/sw.js`) +- Node 22.18+ is required: `scripts/*.ts` run with Node's native type stripping. + +## Architecture + +- `src/utils/` — core logic, framework-free and unit-tested: + - `scan.ts` (device scan), `deviceSource.ts` (`KoboSource`: FS Access handle or `webkitdirectory` FileList) + - `backup.ts` (client-zip streaming; zip.js for AES-256; read-back verification), `backupInfo.ts`/`checksum.ts` (light helpers) + - `restore.ts` (parse, full/merge restore, safety snapshot + undo, target inspection), `merge.ts` (reading-data merge) + - `koboDatabase.ts` (sql.js), `koboDevice.ts` (`.kobo/version`), `koboCovers.ts` (`.kobo-images` qhash) +- Heavy modules (`backup.ts`, `restore.ts`, `export.ts`, sql.js) are **dynamically imported** by hooks/pages — keep them out of the initial bundle. +- `src/router/` — History-API router; routes and localized slugs in `routes.ts` (EN: `/restore`, IT: `/it/ripristino`). +- `src/i18n/` — `en.ts` defines the message shape (`Messages`); `it.ts` must match it (compiler + `i18n.test.ts` enforce keys and placeholders). Use `useI18n()` from `core.ts`; never hard-code UI strings. +- `src/seo/` — `meta.ts` (title/description/canonical/hreflang/JSON-LD) is shared by `useDocumentMeta` (client) and `head.ts` (prerender). +- `src/sw/` — service worker (offline precache + streamed downloads for Firefox/Safari) and its page-side protocol. +- State: Zustand store (`stores/koboStore.ts`) — device session in memory, backup history persisted with `skipHydration` (rehydrated after mount). + +## Rules + +- Components must be SSR-safe: no `window`/`localStorage` during render (use effects, `useSyncExternalStore` server snapshots, `useLocalFlag`). `app.test.tsx` and `hydration.test.tsx` render every route on the server and hydrate it. +- Restore is destructive: keep the safety snapshot, never delete protected dirs (`.kobo`, dot-folders), reject traversal/hidden paths, keep book cleanup opt-in. +- Kobo `TimeSpentReading` is in **seconds** (converted to minutes in `koboDatabase.ts`); the schema version lives in `DbVersion`, not `PRAGMA user_version`. +- Write a failing test first for bug fixes. Real Kobo backups for integration tests go in `fixtures/` or `$KOBUP_FIXTURES_DIR` — **never commit them** (personal data, account tokens). +- CSP in `vercel.json` only allows same-origin connections: do not add third-party scripts, fonts or APIs. +- Adding a page: route in `routes.ts`, component in `pages/`, entry in `App.tsx` `PAGES`, texts in `en.ts` + `it.ts` (`meta.pages`, `nav`), then check the prerendered output. diff --git a/README.md b/README.md index 403e5b3..1e61100 100644 --- a/README.md +++ b/README.md @@ -1,247 +1,167 @@ -# KoBup — Free Kobo Backup Tool +# KoBup — Free Kobo Backup & Restore Tool -> **The easiest way to backup your Kobo e-reader library.** Back up books, annotations, highlights, and reading progress — 100% in your browser, zero uploads, zero accounts. +> **The easiest way to back up your Kobo e-reader.** Books, highlights, notes and reading progress — processed 100% in your browser. Nothing is uploaded, no account needed. -🌐 **Live app:** [kobup.org](https://www.kobup.org/) +**Live app:** [kobup.org](https://www.kobup.org/) · [Italiano](https://www.kobup.org/it) -[![Made with React](https://img.shields.io/badge/Made%20with-React-61DAFB?logo=react)](https://react.dev/) -[![Powered by Vite](https://img.shields.io/badge/Powered%20by-Vite-646CFF?logo=vite)](https://vitejs.dev/) -[![Styled with TailwindCSS](https://img.shields.io/badge/Styled%20with-TailwindCSS-38B2AC?logo=tailwind-css)](https://tailwindcss.com/) +[![Made with React](https://img.shields.io/badge/Made%20with-React%2019-61DAFB?logo=react)](https://react.dev/) +[![Powered by Vite](https://img.shields.io/badge/Powered%20by-Vite%208-646CFF?logo=vite)](https://vite.dev/) +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript)](https://www.typescriptlang.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) --- ## What is KoBup? -**KoBup** ([kobup.org](https://www.kobup.org/)) is a free, open-source web tool that lets you create a complete backup of your Kobo e-reader directly in the browser — no app to install, no account to create, no data ever leaving your device. +**KoBup** is a free, open-source web app that backs up and restores Kobo e-readers directly in the browser. Protect your library before a factory reset, move it to a new Kobo, or export your highlights to Obsidian and Anki — no installation, no server, no account. -Whether you want to protect against a factory reset, switch to a new Kobo device, or simply keep a safe copy of your books and reading progress, KoBup handles it in a few clicks. +## Features ---- - -## ✨ Features - -- **🔒 100% Private** — All processing happens locally in your browser. Your books never touch a server. -- **📚 Complete Kobo Backup** — Backs up sideloaded books (EPUB, PDF), the Kobo database, annotations, highlights, and reading progress. -- **⚡ Handles Any Library Size** — Streaming ZIP engine writes directly to disk, so even 4 GB+ libraries back up and restore without running out of memory. -- **📊 Library Dashboard** — Once connected, explore your library interactively: filter books by progress (Read, Reading, Unread), search by title/author, and view reading stats. -- **🖼️ Real Cover Extraction** — Extracts actual cover thumbnails from `.kobo/images` using suffix match resolution, with a smart fallback system for missing covers. -- **🎒 Advanced Notes Exporter** — Export all annotations to Obsidian (ZIP of Markdown files with YAML frontmatter) or Anki (escaped CSV ready for flashcard import). -- **💾 Database-Only Mode** — Skip the book files and back up only the database (reading progress, highlights, annotations) for a fast, small backup. -- **🔄 Full Restore** — Restore your entire library to a new or reset Kobo device. -- **🌐 No Installation** — Works directly in Chrome or Edge — just visit [kobup.org](https://www.kobup.org/). -- **📖 Open Source** — Fully transparent, auditable code. Anyone can inspect exactly what the app does. -- **🎨 Beautiful UI** — Clean, Kobo-inspired design with an intuitive step-by-step wizard. - ---- - -## 🚀 Quick Start - -### Create a Kobo Backup - -1. Open **[kobup.org](https://www.kobup.org/)** in Chrome or Edge (desktop) -2. Connect your Kobo e-reader via USB and unlock it -3. Click **"Create Backup"** -4. Select your Kobo device folder when prompted -5. Choose what to include (books, annotations, reading progress) -6. Chrome will ask where to save the ZIP file — pick a location -7. Done! Store the backup in a safe place (cloud, external drive) +- **Private by design** — everything runs locally (JavaScript + WebAssembly). A strict Content Security Policy only allows the page to talk to its own origin, so it _cannot_ upload your library. +- **Complete backup** — the Kobo database (progress, highlights, notes, bookmarks, collections), sideloaded books, and optionally reading settings, custom fonts and sleep screens. +- **Optional AES-256 encryption** — recommended for cloud storage: the Kobo database also contains your account sign-in tokens. +- **Verified backups** — every archive is read back after writing and the database SHA-256 is checked; restores flag archives whose database does not match. +- **Move to a new Kobo ("merge" restore)** — copies progress, highlights and collections of your books into the new device's own database while keeping its account, store library and settings. A classic "replace everything" restore is also available. +- **Safe restores** — the current device database is saved to `.kobo/KoboReader.sqlite.before-restore` first, with one-click undo. Existing book folders are never deleted unless you ask; paths into hidden/system folders are refused. +- **Any library size** — streaming ZIP engine: Chrome/Edge write straight to disk; Firefox/Safari stream through a service worker. Memory stays flat even for multi-GB libraries. +- **Library viewer** — real Kobo cover thumbnails (`.kobo-images`, loaded lazily), search/filter/sort, collections, reading statistics. +- **Highlight exports** — Obsidian (one Markdown note per book with YAML frontmatter), Anki (CSV), or a single book. +- **Backup history** — verify or restore previous backups in one click (the file handle is stored locally in IndexedDB). +- **English and Italian**, accessible (WCAG-minded: keyboard navigation, focus management, ARIA), installable and **works offline** (PWA). -### Explore Library & Export Notes +## Quick start -1. Connect your Kobo device and run the scanning process. -2. Click **"Open Dashboard"** or select **Dashboard** in the header. -3. Browse your books, check your reading statistics, or view highlighted notes. -4. Go to the **Annotations** tab to export all highlights/notes directly to Obsidian or Anki. +### Back up -### Restore a Kobo Backup +1. Open [kobup.org/backup](https://www.kobup.org/backup) on a computer. +2. Connect your Kobo via USB, unlock it and tap **Connect**. +3. Select the Kobo drive (usually `KOBOeReader`) and review what was found. +4. Choose what to include, optionally set a password, and save the ZIP. -1. Open **[kobup.org](https://www.kobup.org/)** in Chrome or Edge -2. Connect the target Kobo via USB -3. Click **"Restore Backup"** -4. Select your `kobo_backup_*.zip` file -5. Select the Kobo device folder -6. Confirm and wait — your library is restored +### Move to a new Kobo / restore ---- - -## 🌐 Browser Compatibility - -| Browser | Backup | Restore | Notes | -| -------------- | ------ | ------- | ------------------------------------ | -| **Chrome 86+** | ✅ | ✅ | Recommended | -| **Edge 86+** | ✅ | ✅ | Fully supported | -| **Opera 72+** | ✅ | ✅ | Fully supported | -| **Brave** | ⚠️ | ⚠️ | Enable File System API in settings | -| **Firefox** | ❌ | ❌ | File System Access API not supported | -| **Safari** | ❌ | ❌ | File System Access API not available | - -> **Why Chrome/Edge only?** KoBup uses the [File System Access API](https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API) to read directly from your Kobo device and write the backup directly to disk without loading everything into memory. This API is only available in Chromium-based browsers. +1. Open [kobup.org/restore](https://www.kobup.org/restore) in Chrome, Edge, Opera or Brave. +2. Select your `kobo_backup_*.zip` (enter the password if it is encrypted). +3. Select the Kobo drive and review the compatibility check. +4. Choose **Merge reading data** (new or reset Kobo) or **Replace everything** (same Kobo), then start. ---- - -## 📋 What Gets Backed Up? +The [step-by-step guide](https://www.kobup.org/guide) and [FAQ](https://www.kobup.org/faq) cover the details. -### Always Included +## Browser support -- ✅ **Kobo Database** (`KoboReader.sqlite`) — reading progress, bookmarks, highlights, collections -- ✅ **Book Files** — all sideloaded EPUB, PDF, and other ebook files _(optional: can be skipped for a database-only backup)_ +| Browser | Backup | Library & exports | Restore | +| ----------------- | ------ | ----------------- | ------- | +| Chrome / Edge 86+ | Yes | Yes | Yes | +| Opera / Brave | Yes | Yes | Yes | +| Firefox (desktop) | Yes\* | Yes | No | +| Safari (desktop) | Yes\* | Yes | No | +| Mobile browsers | No | No | No | -### Optional (User Choice) +\* Folder selection via `` and a streamed download through the service worker. Restoring needs the [File System Access API](https://developer.mozilla.org/docs/Web/API/File_System_API) to write to the Kobo, which only Chromium browsers provide. -- 📝 **Annotations & Highlights** — all your notes and highlighted passages, also exported as Markdown -- 📖 **Reading Progress** — current page positions and reading statistics -- ⚙️ **Device Settings** — font and reading preferences +## What gets backed up -### Not Included +| Content | Included | +| -------------------------------------------------------------- | ---------------------------------- | +| `KoboReader.sqlite` (progress, highlights, notes, collections) | Always | +| Sideloaded books (EPUB, KEPUB, PDF, CBZ, …) | Optional (database-only mode) | +| Readable Markdown copy of all highlights | Optional | +| `Kobo eReader.conf`, `fonts/`, `.kobo/screensaver/` | Optional ("Device settings") | +| Kobo store purchases | No — re-download from your account | -- ❌ **Kobo Store Purchases** — re-downloadable from your Kobo account -- ❌ **System / Firmware Files** — not needed for library backup +Archive layout: `KoboReader.sqlite`, `books/`, `device/`, `annotations/`, `backup-metadata.json`, `README.txt`. ---- +## Privacy -## 🔐 Privacy & Security +- No backend: the Kobo is read and written through browser APIs, locally. +- Fonts and all assets are self-hosted; no third-party requests. +- The hosted site uses cookieless [Vercel Analytics](https://vercel.com/docs/analytics) for anonymous page views. Self-hosted copies can drop it. +- Details: [privacy policy](https://www.kobup.org/privacy). -**Your data never leaves your device.** +## Technology -1. The app runs entirely in your browser — there is no backend server -2. Your books and database are read locally via the File System Access API -3. The backup ZIP is written directly to your chosen location on disk -4. The only external service used is [Vercel Analytics](https://vercel.com/analytics) — cookieless, anonymous page-view counts only -5. No account, no login, no tracking of what you read +| Layer | Technology | +| ----------------- | --------------------------------------------------------------- | +| UI | React 19, TypeScript (strict), Tailwind CSS 4 | +| Build | Vite 8 (Rolldown), build-time prerendering (SSG) of every route | +| State | Zustand 5 (+ IndexedDB for backup file handles) | +| SQLite in browser | sql.js (WebAssembly) | +| ZIP | client-zip (streaming write), @zip.js/zip.js (read, AES-256) | +| Offline/streaming | Service worker (precache + streamed downloads) | +| Tests | Vitest, Testing Library, happy-dom | +| Hosting | Vercel | -The complete source code is public on GitHub. You can audit every line, or self-host your own copy with zero analytics. - ---- - -## 🛠️ Technology Stack - -| Layer | Technology | -| ----------------- | ---------------------------------------------- | -| UI Framework | React 18.3+ | -| Build Tool | Vite 5+ | -| Styling | TailwindCSS 3.4+ | -| State | Zustand | -| SQLite in browser | sql.js (WebAssembly) | -| ZIP creation | **client-zip** (streaming, replaces JSZip) | -| ZIP extraction | **@zip.js/zip.js** (streaming, replaces JSZip) | -| File System | File System Access API + browser-fs-access | -| Testing | Vitest | -| Analytics | Vercel Analytics (cookieless) | -| Hosting | Vercel | - ---- - -## 🏗️ Project Structure +## Project structure ``` -KoboOfflineBackup/ -├── src/ -│ ├── components/ -│ │ ├── common/ # Button, Card, Checkbox, etc. -│ │ ├── layout/ # Header, Footer -│ │ ├── backup/ # Backup wizard steps & CoverPreview -│ │ └── restore/ # Restore wizard steps -│ ├── hooks/ -│ │ ├── useBackup.js # Backup orchestration (streaming-first) -│ │ ├── useKoboDevice.js # Device scanning -│ │ └── useRestore.js # Restore orchestration -│ ├── pages/ # Home, LibraryDashboard, Backup, Restore, History, FAQ, etc. -│ ├── utils/ -│ │ ├── backup.js # ZIP creation via client-zip (streaming) -│ │ ├── restore.js # ZIP extraction via @zip.js/zip.js (streaming) -│ │ ├── koboDatabase.js # SQLite parsing with sql.js -│ │ ├── koboCovers.js # Cover image extraction utility -│ │ ├── export.js # Obsidian Markdown & Anki CSV Exporters -│ │ └── fileSystem.js # File System Access API wrapper -│ └── App.jsx -├── public/ -│ ├── robots.txt -│ ├── sitemap.xml -│ └── sql-wasm.wasm -├── index.html # SEO meta tags, OG, JSON-LD -├── vercel.json # COOP/COEP headers for SharedArrayBuffer -└── package.json +src/ +├── pages/ # Home, Backup, Restore, Library, History, Guide, Faq, Privacy, NotFound +├── components/ # common/ (Button, Modal, Tabs, Alert…), layout/, device/, library/ +├── hooks/ # useDevice, useBackup, useRestore, useFeatureDetection, useLocalFlag +├── i18n/ # en.ts (defines the message shape), it.ts, core.ts (formatters) +├── router/ # path-based router with localized slugs (/it/ripristino…) +├── seo/ # per-page meta, hreflang, JSON-LD (shared by client and prerender) +├── sw/ # service worker + streaming download protocol +├── utils/ # scan, backup, restore, merge, koboDatabase, koboCovers, deviceSource… +├── entry-server.tsx # prerender entry +└── main.tsx # hydration + service worker registration +scripts/ +├── postbuild.ts # prerender pages, sitemap.xml, build sw.js with precache list +└── make-icons.ts # generates the PWA icons ``` ---- +## Development -## 🧑‍💻 Local Development +Requires Node.js 22.18+ (TypeScript scripts run natively). ```bash -# Clone -git clone https://github.com/Fanfulla/KoboOfflineBackup.git -cd KoboOfflineBackup - -# Install dependencies npm install - -# Start dev server -npm run dev - -# Run unit tests -npm run test - -# Build for production -npm run build - -# Preview production build -npm run preview +npm run dev # dev server +npm run check # typecheck + lint + tests + production build +npm run build # vite build + prerender + sitemap + service worker +npm run preview # serve dist/ ``` ---- +Integration tests can also run against real Kobo backups: put `kobo_backup_*.zip` files in `fixtures/` (git-ignored, they contain personal data) or point `KOBUP_FIXTURES_DIR` to them. -## ⚠️ Disclaimer +## Disclaimer -This is a **personal project**, shared as-is under the MIT License. +A personal project shared as-is under the MIT License. Keep independent copies of your books. Not affiliated with or endorsed by Rakuten Kobo Inc. Issues and pull requests are welcome. -- No support, assistance, or updates are guaranteed -- Always keep a separate copy of your books independently of this tool -- Not affiliated with or endorsed by Rakuten Kobo Inc. -- Issues and pull requests are welcome but may not receive a timely response +## License ---- - -## 📝 License +MIT — see [LICENSE](LICENSE). -MIT License — see [LICENSE](LICENSE) for details. +## Changelog ---- +### v2.0.0 (September 2026) -## 🗺️ Changelog +- **Fixes** — reading time was shown 60× too high (Kobo stores seconds); the restored database now really leaves WAL mode (`journal_mode` typo); schema version read from `DbVersion`; real device model/firmware from `.kobo/version`; restore errors are visible; backup/restore options that did nothing now work or were removed. +- **Covers** — found in `.kobo-images` (current firmware) via Nickel's image hash; lazy-loaded. +- **Restore** — merge mode for new devices, compatibility check (model, firmware, schema), pre-restore safety copy with undo, password-protected backups, integrity check, personalisation files. +- **Backup** — AES-256 encryption, read-back verification, settings/fonts/screensavers, warning when the Kobo has unsaved (WAL) changes, streamed downloads in Firefox/Safari. +- **Security** — Content Security Policy, self-hosted fonts (no Google Fonts), hidden-folder path guard. +- **UI** — rewritten in TypeScript: real URLs, English + Italian, accessibility pass, standalone library, history with verify/restore. +- **SEO** — prerendered pages with localized metadata, hreflang, structured data (FAQPage, HowTo, WebApplication), real sitemap. +- **PWA** — installable, works offline. +- **Tooling** — React 19, Vite 8, Tailwind 4, Zustand 5, TypeScript strict, ESLint 10, CI; 130+ tests. ### v1.2.1 (May 2026) — hardening -- **Safer restore (behavior change)** — "Remove existing book folders" is now **opt-in**. By default restore overwrites backed-up files without recursively deleting device folders, so books added _after_ a backup are no longer wiped when restoring an older one. -- **Path-traversal guard** — restore refuses any book path containing `..` (defense-in-depth against a crafted/corrupt backup ZIP), and the cleanup step never touches protected folders (`.kobo`, etc.). -- **CSV injection guard** — Anki export prefixes fields starting with `= + - @` so they can't run as formulas if opened in a spreadsheet. -- **Obsidian export** — de-duplicates filenames so books with identical titles no longer overwrite each other. -- **Fixes** — cover-thumbnail object-URL leak, removed dead code, and corrected the Vite `zip` chunk (was empty after the JSZip → client-zip/zip.js migration). -- **Tests** — added an integration suite that runs the restore engine end-to-end against an in-memory device and real Kobo backups (both flat and nested formats). +- Restore no longer deletes existing book folders by default; path-traversal guard; CSV-injection guard for Anki export; Obsidian filename de-duplication; integration tests against real backups. ### v1.2 (May 2026) -- **Library Dashboard** — responsive UI grid to view connected books, filter by status, read metadata, and check reading time/metrics. -- **Kobo Covers Extractor** — parse real book covers from `.kobo/images` using suffix match resolution logic. -- **Advanced Notes Exporter** — export all highlights to Obsidian Markdown ZIP (with YAML frontmatter metadata) or Anki CSV (escaped flashcards). -- **Streaming ZIP Restore** — refactored restore logic to use `@zip.js/zip.js` streams, preventing client memory exhaustion when restoring large files. -- **Vitest Test Suite** — added unit tests for cover parsing and Obsidian/Anki exporters. +- Library dashboard, cover extraction, Obsidian/Anki exporter, streaming ZIP restore, Vitest suite. ### v1.1 (March 2026) -- **Streaming backup engine** — replaced JSZip with client-zip; files are written to disk one at a time, fixing OOM crashes on large libraries (4 GB+, 500+ books) -- **Database-only backup** — option to skip book files and back up only reading progress, annotations, and highlights -- **Accurate size estimate** — estimated backup size now reflects actual file sizes -- **Error visibility** — backup failures now show a clear error message instead of silently restarting -- **Vercel Analytics** — cookieless, privacy-preserving page-view tracking +- Streaming backup engine (client-zip), database-only backup, accurate size estimate, Vercel Analytics. ### v1.0 (January 2026) -- Initial release -- Full backup and restore functionality -- Browser compatibility detection -- Privacy-first architecture +- Initial release. --- -_Made with ❤️ for the Kobo community — [kobup.org](https://www.kobup.org/)_ +_Made for the Kobo community — [kobup.org](https://www.kobup.org/)_ diff --git a/package-lock.json b/package-lock.json index 4a0f1a9..3c4b824 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "kobup", - "version": "1.3.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kobup", - "version": "1.3.0", + "version": "2.0.0", "dependencies": { "@fontsource/oswald": "^5.3.0", "@fontsource/poppins": "^5.3.0", @@ -22,7 +22,6 @@ "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.2", - "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@types/node": "^22.20.4", "@types/react": "^19.3.0", @@ -44,16 +43,9 @@ "vitest": "^5.0.1" }, "engines": { - "node": ">=22.12.0" + "node": ">=22.18.0" } }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", - "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1109,6 +1101,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -1178,42 +1236,6 @@ "node": ">=18" } }, - "node_modules/@testing-library/jest-dom": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", - "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=22", - "npm": ">=6", - "yarn": ">=1" - }, - "peerDependencies": { - "@testing-library/dom": ">=10 <11", - "vitest": ">= 0.32" - }, - "peerDependenciesMeta": { - "vitest": { - "optional": true - } - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", - "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", - "dev": true, - "license": "MIT" - }, "node_modules/@testing-library/react": { "version": "16.3.3", "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", @@ -1980,13 +2002,6 @@ "node": ">= 8" } }, - "node_modules/css.escape": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", - "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", - "dev": true, - "license": "MIT" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2573,16 +2588,6 @@ "node": ">=0.8.19" } }, - "node_modules/indent-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", - "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3031,16 +3036,6 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -3345,20 +3340,6 @@ "dev": true, "license": "MIT" }, - "node_modules/redent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", - "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", @@ -3469,19 +3450,6 @@ "dev": true, "license": "MIT" }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", diff --git a/package.json b/package.json index 59a9bd8..964cb9a 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "kobup", "private": true, - "version": "1.3.0", + "version": "2.0.0", "type": "module", "description": "KoBup - free, privacy-first backup & restore for Kobo e-readers that runs 100% in your browser", "engines": { - "node": ">=22.12.0" + "node": ">=22.18.0" }, "scripts": { "dev": "vite", @@ -35,7 +35,6 @@ "@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.3", "@testing-library/dom": "^10.4.2", - "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@types/node": "^22.20.4", "@types/react": "^19.3.0", diff --git a/src/router/context.ts b/src/router/context.ts index 382f3d4..8f9f672 100644 --- a/src/router/context.ts +++ b/src/router/context.ts @@ -17,10 +17,6 @@ export function useRouter(): RouterValue { return value; } -export function useLang(): Lang { - return useRouter().route.lang; -} - /** Navigate to a route in the current language. */ export function useNavigateTo() { const { navigate, route } = useRouter(); diff --git a/src/types/kobo.ts b/src/types/kobo.ts index 0746790..2f58a1b 100644 --- a/src/types/kobo.ts +++ b/src/types/kobo.ts @@ -141,13 +141,6 @@ export interface BackupMetadata { compatibility?: { minAppVersion: string; supportedDevices: string[] }; } -export interface ProgressState { - stage: string; - percent: number; - filesProcessed: number; - totalFiles: number; -} - export type BackupStage = 'preparing' | 'books' | 'settings' | 'annotations' | 'metadata' | 'finalizing' | 'verifying' | 'complete'; diff --git a/src/utils/export.ts b/src/utils/export.ts index edda583..1a182d7 100644 --- a/src/utils/export.ts +++ b/src/utils/export.ts @@ -1,8 +1,6 @@ import { downloadZip } from 'client-zip'; import type { KoboAnnotation, KoboBook } from '../types/kobo.ts'; -export type AnnotationsByBook = Record; - type BookLike = Pick & Partial; type AnnotationLike = Partial; diff --git a/src/utils/fileSystem.ts b/src/utils/fileSystem.ts index 5c66f7f..703a388 100644 --- a/src/utils/fileSystem.ts +++ b/src/utils/fileSystem.ts @@ -4,17 +4,6 @@ import { FileSystemError, ERROR_CODES } from './errors.ts'; -export interface DeviceFile { - handle: FileSystemFileHandle; - path: string; - name: string; -} - -/** Folders never descended into while scanning (hidden/system). */ -function isSkippedDirectory(name: string): boolean { - return name.startsWith('.') || name === 'System Volume Information'; -} - /** * Let the user pick a directory. Returns null if the dialog is dismissed. */ @@ -47,18 +36,6 @@ export function selectKoboDirectory( return selectDirectory({ id: 'kobo-device', mode }); } -/** Read a file handle fully into memory. */ -export async function readFile(fileHandle: FileSystemFileHandle): Promise { - try { - return await (await fileHandle.getFile()).arrayBuffer(); - } catch (error) { - throw new FileSystemError('Failed to read file', ERROR_CODES.FS_READ_ERROR, { - filename: fileHandle.name, - originalError: error, - }); - } -} - /** Trigger a browser download for an in-memory blob. */ export function downloadBlob(blob: Blob, fileName: string): { filename: string; size: number } { const url = URL.createObjectURL(blob); @@ -75,67 +52,10 @@ export function downloadBlob(blob: Blob, fileName: string): { filename: string; return { filename: fileName, size: blob.size }; } -/** Recursively list files, skipping hidden and system folders. */ -export async function getAllFiles(dirHandle: FileSystemDirectoryHandle, path = ''): Promise { - const files: DeviceFile[] = []; - - try { - for await (const entry of dirHandle.values()) { - const entryPath = path ? `${path}/${entry.name}` : entry.name; - - if (entry.kind === 'file') { - files.push({ handle: entry, path: entryPath, name: entry.name }); - } else if (!isSkippedDirectory(entry.name)) { - files.push(...(await getAllFiles(entry, entryPath))); - } - } - } catch (error) { - console.error('Error reading directory:', path || '/', error); - } - - return files; -} - function splitPath(path: string): string[] { return path.replace(/\\/g, '/').split('/').filter(Boolean); } -/** Get a file handle by relative path (e.g. ".kobo/KoboReader.sqlite"). */ -export async function getFileByPath( - dirHandle: FileSystemDirectoryHandle, - path: string, -): Promise { - try { - const parts = splitPath(path); - const fileName = parts.pop()!; - let current = dirHandle; - for (const part of parts) current = await current.getDirectoryHandle(part); - return await current.getFileHandle(fileName); - } catch (error) { - throw new FileSystemError(`File not found: ${path}`, ERROR_CODES.FS_NOT_FOUND, { - path, - originalError: error, - }); - } -} - -/** Get a directory handle by relative path (e.g. ".kobo"). */ -export async function getDirectoryByPath( - dirHandle: FileSystemDirectoryHandle, - path: string, -): Promise { - try { - let current = dirHandle; - for (const part of splitPath(path)) current = await current.getDirectoryHandle(part); - return current; - } catch (error) { - throw new FileSystemError(`Directory not found: ${path}`, ERROR_CODES.FS_NOT_FOUND, { - path, - originalError: error, - }); - } -} - /** Write data to a file inside a directory (creating/overwriting it). */ export async function writeFile( dirHandle: FileSystemDirectoryHandle, diff --git a/src/utils/scan.ts b/src/utils/scan.ts index 734bee9..141cd18 100644 --- a/src/utils/scan.ts +++ b/src/utils/scan.ts @@ -10,8 +10,6 @@ import { FileSystemError, ERROR_CODES } from './errors.ts'; import type { KoboSource } from './deviceSource.ts'; import type { ScanResult, ScanWarning } from '../types/kobo.ts'; -export const SCAN_STEPS = 4; - export const DATABASE_PATH = '.kobo/KoboReader.sqlite'; export const SETTINGS_PATH = '.kobo/Kobo/Kobo eReader.conf'; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index be5d13d..8627708 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -18,16 +18,6 @@ export async function isValidKoboDirectory(dirHandle: FileSystemDirectoryHandle } } -const MAX_BACKUP_SIZE = 10 * 1024 * 1024 * 1024; // 10 GB - -export function validateBackupFile(file: File | null | undefined): ValidationResult { - if (!file) return { valid: false, error: 'No file provided' }; - if (!file.name.toLowerCase().endsWith('.zip')) return { valid: false, error: 'File must be a ZIP archive' }; - if (file.size > MAX_BACKUP_SIZE) return { valid: false, error: 'Backup file is too large (max 10GB)' }; - if (file.size < 1024) return { valid: false, error: 'Backup file is too small to be valid' }; - return { valid: true }; -} - export function validateBackupMetadata(metadata: unknown): ValidationResult { if (!metadata || typeof metadata !== 'object') return { valid: false, error: 'No metadata provided' };