commit 352a3d5e88f7a695b21107673021356aeb567afd Author: kaivalya Date: Thu Jun 18 13:06:57 2026 +0200 Initial commit diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..9c7686a --- /dev/null +++ b/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"5ade3667-a4e8-4863-b973-cb39073bdd6a","pid":5817,"procStart":"18931","acquiredAt":1781606447105} \ No newline at end of file diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..bcda697 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Read", + "Glob", + "Grep", + "Edit", + "Write", + "MultiEdit", + "Bash(git *)", + "Bash(ls *)", + "Bash(echo *)", + "Bash(cat *)", + "Bash(grep *)", + "Bash(head *)", + "Bash(tail *)", + "Bash(find *)", + "Bash(sort *)", + "Bash(sed *)", + "Bash(awk *)", + "Bash(xargs *)", + "Bash(mkdir *)", + "Bash(cd *)", + "Bash(curl *)", + "Bash(nohup *)", + "Bash(sleep *)", + "Bash(seq *)", + "Bash(kill *)", + "Bash(pkill *)", + "Bash(lsof *)", + "Bash(true *)", + "Bash(break *)", + "Bash(npm run *)", + "Bash(npx prettier*)", + "Bash(python *)", + "Bash(python3 *)", + "Bash(pip *)", + "Bash(./venv/bin/python *)", + "Bash(cd tests && .venv/bin/python *)", + "Bash(cd *)" + ], + "ask": ["WebFetch", "WebSearch", "Bash(git push*)", "Bash(curl *)"], + "deny": [ + "Read(*.env)", + "Read(*.env.*)", + "Glob(*.env)", + "Glob(*.env.*)", + "Grep(*.env)", + "Grep(*.env.*)" + ] + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..593ad7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,84 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-format: + name: Lint & Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Check formatting (Prettier) + run: npm run format:check + + - name: Lint (ESLint) + run: npm run lint + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build production bundle + run: npm run build + + test: + name: Tests (${{ matrix.browser }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: [firefox, chromium] + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + + - name: Install Node dependencies + run: npm ci + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + cache-dependency-path: tests/requirements.txt + + - name: Install Python dependencies + run: pip install -r tests/requirements.txt + + - name: Install Playwright browsers + run: python -m playwright install --with-deps ${{ matrix.browser }} + + - name: Run tests + run: python -m pytest tests/ --browser ${{ matrix.browser }} -v + env: + CI: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd5a7d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +dev-dist/ + +# SQLite WASM (copied from node_modules at build time) +public/sqlite3.wasm +public/sqlite3-opfs-async-proxy.js + +# Python venv +tests/.venv/ +__pycache__/ +.pytest_cache/ + +# Logs +*.log + +# Editor +.idea/ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# temp files +tmp/ + +# Hugo build output +website/public/ +website/resources/ diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..9fffa8c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +public/sqlite3-opfs-async-proxy.js +website/layouts/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0965e7e --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100 +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0158850 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# Agents Rules + +## Project Overview + +TrainUs is a fitness tracking progressive web application built with: + +- **Frontend**: Vue 3 (Composition API) + Vue Router +- **Database**: SQLite WASM (client-side, in-browser) +- **Build tool**: Vite 7 +- **Styling**: Custom CSS + Bootstrap Icons +- **i18n**: i18next + i18next-vue (English, French & Danish) +- **Tests**: Playwright (Python) with pytest +- **Formatting**: Prettier + +## Project Structure + +``` +src/ # All application source code + components/ # Reusable Vue components + composables/ # Vue composables (shared logic) + db/ # SQLite WASM database layer + i18n/ # Internationalization config and translations + router/ # Vue Router configuration + styles/ # CSS styles + utils/ # Utility functions + views/ # Page-level Vue components + App.vue # Root component + main.js # Application entry point +public/ # Static assets served as-is +tests/ # Playwright (Python) test suite + conftest.py # Pytest fixtures (Vite dev server, browser config) + test_step*.py # Test files per feature step +docs/ # Non-website documentation + tutorials/ # Step tutorials (EN & FR) + tutorials/codelabs/ # Google Codelabs format (EN & FR) + decisions-log.md # Decision log + plan.md # Feature plan and progress tracking + specification.md # Project specification +website/ # Hugo project site (docs + blog), two separate sections + # by audience, each with its own nav entry + content/docs/ # User docs ("Documentation" in nav). Diataxis split, + # both languages: demarrage-rapide/ (tutorial), + # guides-pratiques.{en,fr}.md (how-to), user-guide/ + # (reference), comprendre.{en,fr}.md (explanation). + content/developers/ # Contributor docs ("Developers"/"Développeurs" in nav): + # technical.en.md, technical.fr.md + content/blog/ # Blog posts (news, release announcements) +``` + +## Build & Run Commands + +- `npm run dev` -- Start Vite dev server +- `npm run build` -- Production build to `dist/` +- `npm run preview` -- Preview production build +- `npm run format` -- Format code with Prettier +- `npm run format:check` -- Check formatting + +## Plan + +- Prepare a step-by-step plan to implement one feature at a time (do not rush the full application in one shot) +- Validate the plan with me before doing anything +- Validation is required between each step + +## Code + +Respect these principles: + +- KISS (Keep It Simple, Stupid) +- SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) +- DRY (Don't Repeat Yourself) +- Minimal impact: prefer small, focused changes over large refactors +- All application source code goes in `src/` +- All new Vue components use Composition API with ` + + diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000..97adc43 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "jsx": "preserve", + "baseUrl": "." + }, + "include": ["src/**/*", "vite.config.js"], + "exclude": ["node_modules", "dist"] +} diff --git a/notice.md b/notice.md new file mode 100644 index 0000000..29e2236 --- /dev/null +++ b/notice.md @@ -0,0 +1,15 @@ +TrainUs — an open, local-first, offline-capable fitness tracking application where you can create and share your own training sessions. +Copyright (C) 2026 [David Florance](http://kaivalya.eu) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..18842df --- /dev/null +++ b/package-lock.json @@ -0,0 +1,7144 @@ +{ + "name": "trainus", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "trainus", + "version": "1.0.0", + "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.51.2-build7", + "bootstrap-icons": "^1.13.1", + "chart.js": "^4.5.1", + "dompurify": "^3.4.7", + "i18next": "^25.8.17", + "i18next-vue": "^5.4.0", + "marked": "^18.0.4", + "vue": "^3.5.25", + "vue-chartjs": "^5.3.3", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@vitejs/plugin-vue": "^6.0.2", + "eslint": "^10.5.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-vue": "^10.9.2", + "prettier": "^3.8.1", + "vite": "^7.3.1", + "vite-plugin-pwa": "^1.3.0" + } + }, + "node_modules/@apideck/better-ajv-errors": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz", + "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonpointer": "^5.0.1", + "leven": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@babel/code-frame": { + "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.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "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.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", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "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.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "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.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "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.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "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": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "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.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.7.tgz", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.7.tgz", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.7.tgz", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "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/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "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_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": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "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.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "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/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "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", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "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==", + "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/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-babel": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz", + "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.18.6", + "@rollup/pluginutils": "^5.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0", + "@types/babel__core": "^7.1.9", + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "@types/babel__core": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-terser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz", + "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "serialize-javascript": "^7.0.3", + "smob": "^1.0.0", + "terser": "^5.17.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "rollup": "^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sqlite.org/sqlite-wasm": { + "version": "3.51.2-build7", + "resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.51.2-build7.tgz", + "integrity": "sha512-11GK8eeFTP4pLCDdFZTwxeQ57bjghDo7JLNw1li+jmhXDr01lUcee2A+Rn6BJhPBU29oqf3OPBXfZtWDzoNCww==", + "license": "Apache-2.0", + "engines": { + "node": ">=22" + } + }, + "node_modules/@trickfilm400/rollup-plugin-off-main-thread": { + "version": "3.0.0-pre1", + "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz", + "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ejs": "^3.1.10", + "json5": "^2.2.3", + "magic-string": "^0.30.21", + "string.prototype.matchall": "^4.0.12" + }, + "engines": { + "node": ">=12" + } + }, + "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/@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/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": "MIT" + }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "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/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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/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/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "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/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.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/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "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", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/bootstrap-icons": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.13.1.tgz", + "integrity": "sha512-ijombt4v6bv5CLeXvRWKy7CuM3TRTuPEuGaGKvTV5cz65rQSY8RQ2JcHt6b90cBBAC7s8fsf2EkQDldzCoXUjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "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.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "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/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "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/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "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/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "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/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "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-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/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "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/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.361", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", + "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "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-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "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-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.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "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": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@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": "^8.0.0", + "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.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.9.2.tgz", + "integrity": "sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.0", + "semver": "^7.6.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.3.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=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": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/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": { + "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/eslint/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/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "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": "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": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "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": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz", + "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/bgub/eta?sponsor=1" + } + }, + "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/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" + }, + "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" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/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/filelist/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "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": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "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" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "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" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "dev": true, + "license": "ISC" + }, + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.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==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/i18next": { + "version": "25.8.17", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.17.tgz", + "integrity": "sha512-vWtCttyn5bpOK4hWbRAe1ZXkA+Yzcn2OcACT+WJavtfGMcxzkfvXTLMeOU8MUhRmAySKjU4VVuKlo0sSGeBokA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/i18next-vue": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/i18next-vue/-/i18next-vue-5.4.0.tgz", + "integrity": "sha512-GDj0Xvmis5Xgcvo9gMBJMgJCtewYMLZP6gAEPDDGCMjA+QeB4uS4qUf1MK79mkz/FukhaJdC+nlj0y1qk6NO2Q==", + "license": "MIT", + "peerDependencies": { + "i18next": ">=23", + "vue": "^3.4.38" + } + }, + "node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "dev": true, + "license": "ISC" + }, + "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": ">= 4" + } + }, + "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" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "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_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + } + }, + "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/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "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_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/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/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "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", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "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": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "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", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonpointer": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true, + "license": "MIT" + }, + "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/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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/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.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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": { + "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": ">= 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==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "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" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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", + "engines": { + "node": ">=8" + } + }, + "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", + "engines": { + "node": ">=8" + } + }, + "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/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.0.tgz", + "integrity": "sha512-5YgH9UJd7wVb9hIouI2adWpgqrrICkt070Dnj8EUY1+B4B2P9eRLPAkAAo6NICA7CEhOIeBHl46u9zSNpNu7zA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "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/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "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.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pretty-bytes": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz", + "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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": ">=6" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "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.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "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", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "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.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "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/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/smob": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz", + "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/source-map": { + "version": "0.8.0-beta.0", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", + "integrity": "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==", + "deprecated": "The work that was done in this beta branch won't be included in future versions", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "whatwg-url": "^7.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "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.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/stringify-object": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", + "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/temp-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz", + "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tempy": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz", + "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "temp-dir": "^2.0.0", + "type-fest": "^0.16.0", + "unique-string": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.48.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.48.0.tgz", + "integrity": "sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz", + "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/upath": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", + "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4", + "yarn": "*" + } + }, + "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==", + "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": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "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": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "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": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite-plugin-pwa": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.3.0.tgz", + "integrity": "sha512-c5kMgN+ITrOtHXp8PAtk2uOIEea6XjP/unCGxOWWBzQ6qa65qj/awHg0wf+QF9E/2u9vh86LqxPwzEPNbM2r5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^1.0.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "workbox-build": "^7.4.1", + "workbox-window": "^7.4.1" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-chartjs": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz", + "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz", + "integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, + "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==", + "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" + } + }, + "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==", + "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_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "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" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/workbox-background-sync": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz", + "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-broadcast-update": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz", + "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-build": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz", + "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apideck/better-ajv-errors": "^0.3.1", + "@babel/core": "^7.24.4", + "@babel/preset-env": "^7.11.0", + "@babel/runtime": "^7.11.2", + "@rollup/plugin-babel": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", + "@rollup/plugin-terser": "^1.0.0", + "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1", + "ajv": "^8.6.0", + "common-tags": "^1.8.0", + "eta": "^4.5.1", + "fast-json-stable-stringify": "^2.1.0", + "fs-extra": "^9.0.1", + "glob": "^11.0.1", + "pretty-bytes": "^5.3.0", + "rollup": "^4.53.3", + "source-map": "^0.8.0-beta.0", + "stringify-object": "^3.3.0", + "strip-comments": "^2.0.1", + "tempy": "^0.6.0", + "upath": "^1.2.0", + "workbox-background-sync": "7.4.1", + "workbox-broadcast-update": "7.4.1", + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-google-analytics": "7.4.1", + "workbox-navigation-preload": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-range-requests": "7.4.1", + "workbox-recipes": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1", + "workbox-streams": "7.4.1", + "workbox-sw": "7.4.1", + "workbox-window": "7.4.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/workbox-build/node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/workbox-cacheable-response": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz", + "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-core": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz", + "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-expiration": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz", + "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "idb": "^7.0.1", + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-google-analytics": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz", + "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-background-sync": "7.4.1", + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-navigation-preload": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz", + "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-precaching": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz", + "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-range-requests": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz", + "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-recipes": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz", + "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-cacheable-response": "7.4.1", + "workbox-core": "7.4.1", + "workbox-expiration": "7.4.1", + "workbox-precaching": "7.4.1", + "workbox-routing": "7.4.1", + "workbox-strategies": "7.4.1" + } + }, + "node_modules/workbox-routing": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz", + "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-strategies": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz", + "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1" + } + }, + "node_modules/workbox-streams": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz", + "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "workbox-core": "7.4.1", + "workbox-routing": "7.4.1" + } + }, + "node_modules/workbox-sw": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz", + "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==", + "dev": true, + "license": "MIT" + }, + "node_modules/workbox-window": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz", + "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "^2.0.2", + "workbox-core": "7.4.1" + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7b28ab6 --- /dev/null +++ b/package.json @@ -0,0 +1,45 @@ +{ + "name": "trainus", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "test": "cd tests && .venv/bin/python -m pytest --browser firefox -v", + "test:chromium": "cd tests && .venv/bin/python -m pytest --browser chromium -v", + "test:parallel": "cd tests && .venv/bin/python -m pytest --browser firefox --browser chromium --dist loadfile -n auto -v", + "test:parallel:firefox": "cd tests && .venv/bin/python -m pytest --browser firefox --dist loadfile -n auto -v", + "test:parallel:chromium": "cd tests && .venv/bin/python -m pytest --browser chromium --dist loadfile -n auto -v", + "test:parallel:n": "cd tests && .venv/bin/python -m pytest --browser firefox --browser chromium --dist loadfile -n ${WORKERS:-4} -v", + "site:dev": "cd website && hugo server --buildDrafts --port 1313", + "site:build": "cd website && hugo --minify" + }, + "dependencies": { + "@sqlite.org/sqlite-wasm": "^3.51.2-build7", + "bootstrap-icons": "^1.13.1", + "chart.js": "^4.5.1", + "dompurify": "^3.4.7", + "i18next": "^25.8.17", + "i18next-vue": "^5.4.0", + "marked": "^18.0.4", + "vue": "^3.5.25", + "vue-chartjs": "^5.3.3", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@vitejs/plugin-vue": "^6.0.2", + "eslint": "^10.5.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-vue": "^10.9.2", + "prettier": "^3.8.1", + "vite": "^7.3.1", + "vite-plugin-pwa": "^1.3.0" + } +} diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..fbe09e5 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..806c65d Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..9812483 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..acc1e15 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "TrainUs", + "short_name": "TrainUs", + "description": "Local-first fitness training app", + "lang": "en", + "start_url": "./", + "scope": "./", + "display": "standalone", + "background_color": "#1a1a1a", + "theme_color": "#1a1a1a", + "icons": [ + { + "src": "./icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "./icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/public/mighty-force.svg b/public/mighty-force.svg new file mode 100644 index 0000000..9f19c48 --- /dev/null +++ b/public/mighty-force.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..ec0ed8f --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# TrainUs release script — local steps only. +# Prepares a release commit, tag, source archive, and a draft release notes file. +# Everything that touches Forgejo or a remote server is left to you (see checklist at the end). +# Usage: ./scripts/release.sh +set -euo pipefail + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +RED='\033[0;31m'; DIM='\033[2m' +info() { echo -e " ${GREEN}✓${NC} $*"; } +doing() { echo -e "\n${CYAN}▶${NC} $*"; } +warn() { echo -e " ${YELLOW}⚠${NC} $*"; } +error() { echo -e "\n${RED}✗ Error:${NC} $*" >&2; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$ROOT_DIR" + +# ── Prerequisites ───────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}Checking prerequisites...${NC}" + +command -v npm >/dev/null 2>&1 || error "npm is not installed." +command -v git >/dev/null 2>&1 || error "git is not installed." +info "npm and git are available." + +if [[ -n "$(git status --porcelain)" ]]; then + error "Working tree is not clean. Commit or stash your changes before releasing." +fi +info "Working tree is clean." + +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "$CURRENT_BRANCH" != "main" ]]; then + warn "Current branch is '$CURRENT_BRANCH', not 'main'." +fi +info "On branch: $CURRENT_BRANCH" + +# ── Commits since last release ──────────────────────────────────────────────── +echo "" +echo -e "${BOLD}Changes since last release${NC}" + +LAST_TAG=$(git describe --tags --abbrev=0 HEAD 2>/dev/null || echo "") + +if [[ -n "$LAST_TAG" ]]; then + echo " Last release: $LAST_TAG" + echo " Commits since $LAST_TAG:" + echo "" + COMMIT_LOG=$(git log "${LAST_TAG}..HEAD" --oneline --no-merges) +else + warn "No previous tag found — listing all commits." + echo "" + COMMIT_LOG=$(git log --oneline --no-merges) +fi + +if [[ -z "$COMMIT_LOG" ]]; then + warn "No commits since $LAST_TAG. Are you sure you want to release?" +else + while IFS= read -r line; do + echo -e " ${DIM}${line}${NC}" + done <<< "$COMMIT_LOG" +fi + +echo "" + +# ── Version ─────────────────────────────────────────────────────────────────── +echo -e "${BOLD}Version${NC}" +CURRENT_VERSION=$(node -p "require('./package.json').version") +echo " Current version: $CURRENT_VERSION" +echo -n " New version (semver, e.g. 1.1.0): " +read -r VERSION + +[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \ + || error "Invalid format. Use MAJOR.MINOR.PATCH without a 'v' prefix." + +TAG="v$VERSION" +ARCHIVE="trainus-${TAG}-src.zip" +NOTES_FILE="trainus-${TAG}-notes.md" + +git tag --list | grep -q "^${TAG}$" \ + && error "Tag $TAG already exists locally." + +# ── Plan ────────────────────────────────────────────────────────────────────── +echo "" +echo -e "${BOLD}The script will perform the following steps in order:${NC}" +echo "" +echo " 1. Bump version in package.json and package-lock.json" +echo " $CURRENT_VERSION → $VERSION" +echo "" +echo " 2. Build the production app" +echo " npm run build → dist/" +echo "" +echo " 3. Create a release commit on branch '$CURRENT_BRANCH'" +echo " git commit -m \"chore: release $TAG\"" +echo "" +echo " 4. Create an annotated git tag (local only, not pushed)" +echo " git tag -a $TAG -m \"Release $TAG\"" +echo "" +echo " 5. Create a source archive of the current commit" +echo " $ARCHIVE (tracked files only, no node_modules, no dist)" +echo "" +echo " 6. Generate a draft release notes file for you to edit" +echo " $NOTES_FILE (pre-filled with the commit list above)" +echo "" +echo -e "${YELLOW}Nothing will be pushed or sent anywhere. All steps are local.${NC}" +echo "" +echo -n " Continue? [y/N] " +read -r CONFIRM +[[ "$CONFIRM" =~ ^[Yy]$ ]] || { echo " Aborted."; exit 0; } + +# ── Step 1 — Bump version ───────────────────────────────────────────────────── +doing "Step 1/6 — Bumping version in package.json and package-lock.json" +echo " Running: npm version $VERSION --no-git-tag-version" +npm version "$VERSION" --no-git-tag-version --silent +info "package.json and package-lock.json updated to $VERSION." + +# ── Step 2 — Build ──────────────────────────────────────────────────────────── +doing "Step 2/6 — Building the production app" +echo " Running: npm run build" +npm run build +info "Build complete. Output is in dist/." + +# ── Step 3 — Commit ─────────────────────────────────────────────────────────── +doing "Step 3/6 — Creating release commit" +echo " Running: git add package.json package-lock.json" +echo " git commit -m \"chore: release $TAG\"" +git add package.json package-lock.json +git commit -m "chore: release $TAG" +info "Commit created: chore: release $TAG" + +# ── Step 4 — Tag ────────────────────────────────────────────────────────────── +doing "Step 4/6 — Creating annotated git tag" +echo " Running: git tag -a $TAG -m \"Release $TAG\"" +git tag -a "$TAG" -m "Release $TAG" +info "Tag $TAG created locally." + +# ── Step 5 — Archive ────────────────────────────────────────────────────────── +doing "Step 5/6 — Creating source archive" +echo " Running: git archive --format=zip HEAD --output=$ARCHIVE" +git archive --format=zip HEAD --output="$ARCHIVE" +info "Archive created: $ARCHIVE" +echo " Contains: all files tracked by git at commit $(git rev-parse --short HEAD)." +echo " Excludes: node_modules/, dist/, .git/, and any untracked files." + +# ── Step 6 — Release notes file ─────────────────────────────────────────────── +doing "Step 6/6 — Generating draft release notes" +echo " Writing: $NOTES_FILE" + +if [[ -n "$LAST_TAG" ]]; then + RANGE_LABEL="since $LAST_TAG" +else + RANGE_LABEL="(no previous tag — all commits)" +fi + +{ + echo "# TrainUs $TAG — Release notes" + echo "" + echo "" + echo "" + echo "## What's new" + echo "" + echo "" + echo "" + echo "-" + echo "" + echo "## Bug fixes" + echo "" + echo "" + echo "" + echo "-" + echo "" + echo "## Breaking changes" + echo "" + echo "None." + echo "" + echo "---" + echo "" + echo "## Commits $RANGE_LABEL" + echo "" + echo "" + echo "" + if [[ -n "$COMMIT_LOG" ]]; then + while IFS= read -r line; do + echo "- $line" + done <<< "$COMMIT_LOG" + else + echo "- (none)" + fi +} > "$NOTES_FILE" + +info "Draft release notes written to $NOTES_FILE." +echo " Open it in your editor, rewrite the notes, then paste into Forgejo." + +# ── Done — Manual checklist ─────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}${BOLD}Local release steps complete.${NC}" +echo "" +echo -e "${BOLD}Artifacts created:${NC}" +echo " $ARCHIVE ← attach to the Forgejo release" +echo " $NOTES_FILE ← edit and paste as the release body" +echo "" +echo -e "${BOLD}Manual steps remaining:${NC}" +echo "" +echo -e " ${BOLD}1. Edit the release notes${NC}" +echo " Open $NOTES_FILE and rewrite the notes into polished prose." +echo "" +echo -e " ${BOLD}2. Push the commit and tag to Forgejo${NC}" +echo " git push origin $CURRENT_BRANCH" +echo " git push origin $TAG" +echo "" +echo -e " ${BOLD}3. Create the release on Forgejo${NC}" +echo " Go to your repository → Releases → New release" +echo " Tag: $TAG" +echo " Title: TrainUs $TAG" +echo " Body: paste the contents of $NOTES_FILE" +echo " Attach: $ARCHIVE" +echo " Publish the release." +echo "" +echo -e " ${BOLD}4. Deploy the app${NC}" +echo " Upload the contents of dist/ to your static host." +echo "" +echo -e " ${BOLD}5. Deploy the website${NC}" +echo " npm run site:build" +echo " Upload the contents of website/public/ to your website host." +echo "" +echo -e " ${BOLD}6. Publish the release blog post${NC}" +echo " Duplicate website/content/blog/release-template.en.md" +echo " Rename it: $(date +%Y-%m-%d)-release-${TAG}.en.md" +echo " Fill in the placeholders — set draft: false when ready." +echo " Write the French version yourself." +echo " Rebuild and redeploy the website (step 5)." +echo "" +echo -e " ${BOLD}7. Clean up local artifacts${NC}" +echo " Once uploaded and published: rm $ARCHIVE $NOTES_FILE" +echo "" diff --git a/scripts/screenshots/README.md b/scripts/screenshots/README.md new file mode 100644 index 0000000..08531a5 --- /dev/null +++ b/scripts/screenshots/README.md @@ -0,0 +1,46 @@ +# Screenshot scripts + +Small, standalone Playwright (Python) scripts that drive the app in a browser to produce PNGs for visual investigations or documentation figures. + +These are **not tests** — they contain no assertions. For the automated test suite, see `tests/`. + +## Prerequisites + +- Playwright for Python installed (see `tests/requirements.txt`): + ```bash + pip install -r tests/requirements.txt + python -m playwright install firefox chromium + ``` +- Vite dev server running on port 5199: + ```bash + npm run dev -- --port 5199 --strictPort + ``` + +## Running + +```bash +# All pages, modals and dialogs (seeds DB automatically) +python scripts/screenshots/all_pages.py +python scripts/screenshots/all_pages.py --browser chromium +python scripts/screenshots/all_pages.py --width 390 --height 844 # mobile viewport +python scripts/screenshots/all_pages.py --out-dir /tmp/shots + +# Exercise list only +python scripts/screenshots/exercise_list.py --help +python scripts/screenshots/exercise_list.py # Firefox, 1920x1080 +python scripts/screenshots/exercise_list.py --browser chromium +python scripts/screenshots/exercise_list.py --width 2560 --height 1440 +python scripts/screenshots/exercise_list.py --out /tmp/custom.png +``` + +## Output location + +By default, PNGs are written to `tmp/` at the project root (which is gitignored). Override with `--out PATH` if you need a different location. + +## Conventions for new scripts + +- One script per page/flow; name it after the page (e.g., `exercise_list.py`, `combo_detail.py`). +- Use `argparse` with at least these flags: `--url`, `--browser`, `--width`, `--height`, `--out`. +- Default the output to `tmp/--.png`. +- Seed the database through the UI if the target page requires data; skip seeding when cards already exist so the script is idempotent. +- Keep the script free of assertions — if you want to verify behavior, add a test under `tests/` instead. diff --git a/scripts/screenshots/all_pages.py b/scripts/screenshots/all_pages.py new file mode 100644 index 0000000..56f81aa --- /dev/null +++ b/scripts/screenshots/all_pages.py @@ -0,0 +1,380 @@ +""" +Capture screenshots of every view, modal, and dialog in the TrainUs app. + +The app is driven in English by default (use --lang fr for French screenshots) +with a seeded database so each screenshot shows meaningful content rather +than empty states. + +Intended for visual investigations and documentation figures — not a test. + +Prerequisites: + - Playwright for Python installed (see `tests/requirements.txt`) + - Playwright browsers installed: `python -m playwright install firefox chromium` + - Vite dev server running: `npm run dev -- --port 5199 --strictPort` + +Usage (from project root): + + python scripts/screenshots/all_pages.py + python scripts/screenshots/all_pages.py --browser chromium + python scripts/screenshots/all_pages.py --lang fr + python scripts/screenshots/all_pages.py --out-dir /tmp/screenshots + python scripts/screenshots/all_pages.py --no-mobile + +Options: + + --url URL App URL (default: http://localhost:5199) + --browser NAME firefox | chromium | webkit (default: firefox) + --lang LANG en | fr (default: en) + --out-dir PATH Output directory root (default: tmp/screenshots) + --no-mobile Skip the mobile (390×844) pass + +Exit code: 0 on success, 1 on failure. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +import traceback +from pathlib import Path + +from playwright.sync_api import Page, sync_playwright + + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + +_SEED_SCRIPT = """ +async () => { + const uuid = await window.__repos.settings.get('user_uuid'); + + // ── exercises ────────────────────────────────────────────────────────── + const ex1 = await window.__repos.exercises.create({ + title: 'Push-ups', + description: '**Classic** bodyweight push-up.\\n\\n- Keep your back straight\\n- Full range of motion', + asymmetric: false, alternate: false, image_urls: [], video_urls: [], + default_reps: 15, default_weight: 0, created_by: uuid + }); + const ex2 = await window.__repos.exercises.create({ + title: 'Squats', + description: 'Bodyweight squat — great for legs and glutes.', + asymmetric: false, alternate: false, image_urls: [], video_urls: [], + default_reps: 20, default_weight: 0, created_by: uuid + }); + const ex3 = await window.__repos.exercises.create({ + title: 'Deadlift', + description: 'Barbell deadlift — compound posterior-chain movement.', + asymmetric: false, alternate: false, image_urls: [], video_urls: [], + default_reps: 5, default_weight: 100, created_by: uuid + }); + const ex4 = await window.__repos.exercises.create({ + title: 'Single-leg Romanian Deadlift', + description: 'Balance and hamstring exercise.\\n\\n*Asymmetric — alternate sides.*', + asymmetric: true, alternate: true, image_urls: [], video_urls: [], + default_reps: 10, default_weight: 20, created_by: uuid + }); + + // ── regular combo ────────────────────────────────────────────────────── + const comboId = await window.__repos.combos.create({ + title: 'Morning HIIT', + description: 'Quick high-intensity warm-up combo.', + type: 'NONE', timer_config: null, created_by: uuid + }); + await window.__repos.combos.setExercises(comboId, [ + { exerciseId: ex1, position: 0, defaultReps: 15, defaultWeight: 0 }, + { exerciseId: ex2, position: 1, defaultReps: 20, defaultWeight: 0 } + ]); + + // ── AMRAP combo ──────────────────────────────────────────────────────── + const amrapComboId = await window.__repos.combos.create({ + title: 'AMRAP Blast', + description: '3-minute AMRAP: push-ups and squats. As many rounds as possible!', + type: 'AMRAP', timer_config: {}, created_by: uuid + }); + await window.__repos.combos.setExercises(amrapComboId, [ + { exerciseId: ex1, position: 0, defaultReps: 10, defaultWeight: 0 }, + { exerciseId: ex2, position: 1, defaultReps: 15, defaultWeight: 0 } + ]); + + // ── regular session ──────────────────────────────────────────────────── + const sessId = await window.__repos.sessions.create({ + title: 'Full-Body Workout A', + description: 'A balanced full-body session combining strength and cardio.', + created_by: uuid + }); + await window.__repos.sessions.setItems(sessId, [ + { item_id: ex3, item_type: 'exercise', repetitions: 5, weight: 100.0 }, + { item_id: comboId, item_type: 'combo', repetitions: 3, weight: 0.0 } + ]); + + // ── AMRAP session (repetitions = minutes for AMRAP type) ─────────────── + const amrapSessId = await window.__repos.sessions.create({ + title: 'AMRAP Cardio', + description: '3-minute AMRAP push-up/squat circuit.', + created_by: uuid + }); + await window.__repos.sessions.setItems(amrapSessId, [ + { item_id: amrapComboId, item_type: 'combo', repetitions: 3, weight: 0.0 } + ]); + + // ── collection ───────────────────────────────────────────────────────── + const colId = await window.__repos.collections.create({ + label: 'Week 1 Plan', created_by: uuid + }); + await window.__repos.collections.setSessions(colId, [sessId]); + + // ── execution log so the home page shows history ─────────────────────── + const now = Date.now(); + const logId = await window.__repos.executionLogs.create({ + session_id: sessId, + started_at: new Date(now - 3600000).toISOString(), + finished_at: new Date(now - 3000000).toISOString() + }); + await window.__repos.executionLogs.addItem({ + log_id: logId, exercise_id: ex3, combo_id: null, + round_number: 1, reps_done: 5, weight_used: 100, side: null, + completed: true, timestamp: new Date(now - 3500000).toISOString() + }); + await window.__repos.executionLogs.addItem({ + log_id: logId, exercise_id: ex1, combo_id: comboId, + round_number: 1, reps_done: 15, weight_used: 0, side: null, + completed: true, timestamp: new Date(now - 3400000).toISOString() + }); + + return { ex1, ex2, ex3, ex4, comboId, amrapComboId, sessId, amrapSessId, colId, logId }; +} +""" + +_COUNT_SCRIPT = """ +async () => (await window.__repos.exercises.getAll()).length +""" + + +def wait_for_db(page: Page, url: str) -> None: + page.goto(url) + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + +def ensure_language(page: Page, lang: str) -> None: + current = page.evaluate("async () => window.__repos.settings.get('language')") + if current != lang: + page.evaluate(f"async () => window.__repos.settings.set('language', '{lang}')") + page.reload() + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + +def has_data(page: Page) -> bool: + return page.evaluate(_COUNT_SCRIPT) > 0 + + +# --------------------------------------------------------------------------- +# Screenshot helpers +# --------------------------------------------------------------------------- + +def shot(page: Page, out_dir: Path, name: str, full_page: bool = True) -> None: + time.sleep(0.4) + path = out_dir / f"{name}.png" + page.screenshot(path=str(path), full_page=full_page) + print(f" ✓ {path.name}") + + +def goto(page: Page, url: str, route: str, wait_selector: str | None = None) -> None: + page.goto(f"{url}/#/{route}", wait_until="domcontentloaded") + if wait_selector: + page.wait_for_selector(wait_selector, timeout=8000) + else: + time.sleep(0.3) + + +# --------------------------------------------------------------------------- +# Main capture flow +# --------------------------------------------------------------------------- + +def capture_all(page: Page, url: str, out_dir: Path, lang: str) -> None: + wait_for_db(page, url) + ensure_language(page, lang) + + if not has_data(page): + print(" Seeding database…") + ids = page.evaluate(_SEED_SCRIPT) + print(" Seed complete.") + else: + raise RuntimeError("DB already has data — start with a fresh browser context") + + sess_id = ids['sessId'] + amrap_sess_id = ids['amrapSessId'] + col_id = ids['colId'] + + # ── home (shows recent execution history after seed) ────────────────── + goto(page, url, "home") + shot(page, out_dir, "home") + + # ── exercises ───────────────────────────────────────────────────────── + goto(page, url, "exercises", '[data-testid="exercise-new-btn"]') + shot(page, out_dir, "exercises") + + exercises = page.evaluate("async () => window.__repos.exercises.getAll()") + ex_id = exercises[0]["id"] + + goto(page, url, f"exercises/{ex_id}", '[data-testid="exercise-detail-title"]') + shot(page, out_dir, "exercise-detail") + + goto(page, url, f"exercises/{ex_id}/edit", '[data-testid="exercise-title"]') + shot(page, out_dir, "exercise-edit") + + goto(page, url, "exercises/new", '[data-testid="exercise-title"]') + shot(page, out_dir, "exercise-new") + + # ── combos ──────────────────────────────────────────────────────────── + goto(page, url, "combos", '[data-testid="combo-new-btn"]') + shot(page, out_dir, "combos") + + combos = page.evaluate("async () => window.__repos.combos.getAll()") + combo_id = combos[0]["id"] + + goto(page, url, f"combos/{combo_id}", '[data-testid="combo-detail-title"]') + shot(page, out_dir, "combo-detail") + + goto(page, url, f"combos/{combo_id}/edit", '[data-testid="combo-title"]') + shot(page, out_dir, "combo-edit") + + goto(page, url, "combos/new", '[data-testid="combo-title"]') + shot(page, out_dir, "combo-new") + + # ── sessions ────────────────────────────────────────────────────────── + goto(page, url, "sessions", '[data-testid="session-new-btn"]') + shot(page, out_dir, "sessions") + + goto(page, url, f"sessions/{sess_id}", '[data-testid="session-detail-title"]') + shot(page, out_dir, "session-detail") + + goto(page, url, f"sessions/{sess_id}/edit", '[data-testid="session-title"]') + shot(page, out_dir, "session-edit") + + goto(page, url, "sessions/new", '[data-testid="session-title"]') + shot(page, out_dir, "session-new") + + # ── session execute — regular (progress bar + current step) ─────────── + goto(page, url, f"sessions/{sess_id}/execute", '[data-testid="execute-progress"]') + shot(page, out_dir, "session-execute") + + # ── session execute — AMRAP timer running ───────────────────────────── + # Navigate away first so Vue Router remounts SessionExecuteView (it reuses + # the component when only params change, so onMounted/init() won't re-run + # unless we force a different route in between). + goto(page, url, "sessions", '[data-testid="session-new-btn"]') + goto(page, url, f"sessions/{amrap_sess_id}/execute", '[data-testid="execute-timer"]') + shot(page, out_dir, "session-execute-amrap") + + # ── collections ─────────────────────────────────────────────────────── + goto(page, url, "collections", '[data-testid="collection-new-btn"]') + shot(page, out_dir, "collections") + + goto(page, url, f"collections/{col_id}", '[data-testid="collection-detail-title"]') + shot(page, out_dir, "collection-detail") + + goto(page, url, f"collections/{col_id}/edit", '[data-testid="collection-label"]') + shot(page, out_dir, "collection-edit") + + goto(page, url, "collections/new", '[data-testid="collection-label"]') + shot(page, out_dir, "collection-new") + + # ── stats ───────────────────────────────────────────────────────────── + goto(page, url, "stats", '[data-testid="stats-date-range"]') + shot(page, out_dir, "stats") + + # ── settings — viewport-height slices (page is tall) ────────────────── + goto(page, url, "settings", '[data-testid="settings-language"]') + page.evaluate("window.scrollTo(0, 0)") + shot(page, out_dir, "settings-top", full_page=False) + page.evaluate("window.scrollTo(0, document.body.scrollHeight)") + time.sleep(0.2) + shot(page, out_dir, "settings-bottom", full_page=False) + + # ── about dialog ────────────────────────────────────────────────────── + page.evaluate("window.scrollTo(0, 0)") + page.click('[data-testid="settings-about"]') + page.wait_for_selector('[data-testid="modal-card"]') + shot(page, out_dir, "about-dialog") + + # ── license dialog (stacked on about) ───────────────────────────────── + page.click('[data-testid="about-license-btn"]') + page.wait_for_selector('[data-testid="license-dialog-content"]') + shot(page, out_dir, "license-dialog") + + close_btns = page.locator('[data-testid="modal-close"]') + close_btns.last.click() + close_btns.first.click() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--url", default=os.environ.get("APP_URL", "http://localhost:5199")) + parser.add_argument("--browser", default="firefox", choices=["firefox", "chromium", "webkit"]) + parser.add_argument("--lang", default="en", choices=["en", "fr"]) + parser.add_argument("--out-dir", default=None) + parser.add_argument("--no-mobile", action="store_true") + return parser.parse_args(argv) + + +def default_out_dir(browser: str, lang: str, width: int, height: int) -> Path: + project_root = Path(__file__).resolve().parents[2] + prefix = browser if lang == "en" else f"{lang}-{browser}" + return project_root / "tmp" / "screenshots" / f"{prefix}-{width}x{height}" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + + passes = [(1920, 1080)] + if not args.no_mobile: + passes.append((390, 844)) + + with sync_playwright() as p: + for (w, h) in passes: + out_dir = ( + Path(args.out_dir) / f"{args.browser}-{w}x{h}" + if args.out_dir + else default_out_dir(args.browser, args.lang, w, h) + ) + out_dir.mkdir(parents=True, exist_ok=True) + + print(f"\n── {args.browser} {w}×{h} → {out_dir}") + + browser_type = getattr(p, args.browser) + browser = browser_type.launch(headless=True) + try: + context = browser.new_context(viewport={"width": w, "height": h}) + page = context.new_page() + page.on("dialog", lambda dialog: dialog.accept()) + capture_all(page, args.url, out_dir, args.lang) + count = len(list(out_dir.glob("*.png"))) + print(f"\n Done — {count} screenshots saved") + except Exception as exc: + print(f"\n ERROR: {exc}", file=sys.stderr) + traceback.print_exc() + return 1 + finally: + browser.close() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/screenshots/combo_timers.py b/scripts/screenshots/combo_timers.py new file mode 100644 index 0000000..c5904c1 --- /dev/null +++ b/scripts/screenshots/combo_timers.py @@ -0,0 +1,241 @@ +""" +Capture screenshots of EMOM and TABATA combo timers in Execution Mode, +including the TABATA rest screen — not covered by all_pages.py (which only +seeds an AMRAP combo). + +The app is driven in English by default (use --lang fr for French +screenshots) with a seeded database so each screenshot shows meaningful +content rather than empty states. + +Intended for visual investigations and documentation figures — not a test. + +Prerequisites: + - Playwright for Python installed (see `tests/requirements.txt`) + - Playwright browsers installed: `python -m playwright install firefox chromium` + - Vite dev server running: `npm run dev -- --port 5199 --strictPort` + +Usage (from project root): + + python scripts/screenshots/combo_timers.py + python scripts/screenshots/combo_timers.py --browser chromium + python scripts/screenshots/combo_timers.py --lang fr + python scripts/screenshots/combo_timers.py --out-dir /tmp/screenshots + +Options: + + --url URL App URL (default: http://localhost:5199) + --browser NAME firefox | chromium | webkit (default: firefox) + --lang LANG en | fr (default: en) + --width N Viewport width in px (default: 1920) + --height N Viewport height in px (default: 1080) + --out-dir PATH Output directory (default: tmp/screenshots/combo-timers---x) + +Exit code: 0 on success, 1 on failure. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +import traceback +from pathlib import Path + +from playwright.sync_api import Page, sync_playwright + + +# --------------------------------------------------------------------------- +# Seed helpers +# --------------------------------------------------------------------------- + +# TABATA work/rest kept short (6s / 5s) so the script doesn't have to wait +# long to reach the rest screen. +_SEED_SCRIPT = """ +async () => { + const uuid = await window.__repos.settings.get('user_uuid'); + + const ex1 = await window.__repos.exercises.create({ + title: 'Jumping Jacks', + description: 'Cardio warm-up movement.', + asymmetric: false, alternate: false, image_urls: [], video_urls: [], + default_reps: 20, default_weight: 0, created_by: uuid + }); + const ex2 = await window.__repos.exercises.create({ + title: 'Mountain Climbers', + description: 'Core and cardio movement.', + asymmetric: false, alternate: false, image_urls: [], video_urls: [], + default_reps: 20, default_weight: 0, created_by: uuid + }); + + // ── EMOM combo — work phase is always a 60s slot per exercise ───────── + const emomComboId = await window.__repos.combos.create({ + title: 'EMOM Cardio', description: '3-minute EMOM.', + type: 'EMOM', timer_config: {}, created_by: uuid + }); + await window.__repos.combos.setExercises(emomComboId, [ + { exerciseId: ex1, position: 0, defaultReps: 20, defaultWeight: 0 } + ]); + const emomSessId = await window.__repos.sessions.create({ + title: 'EMOM Session', description: '', created_by: uuid + }); + await window.__repos.sessions.setItems(emomSessId, [ + { item_id: emomComboId, item_type: 'combo', repetitions: 3, weight: 0.0 } + ]); + + // ── TABATA combo ──────────────────────────────────────────────────── + const tabataComboId = await window.__repos.combos.create({ + title: 'TABATA Blast', description: 'Work/rest intervals.', + type: 'TABATA', timer_config: { workTime: 6, restTime: 5 }, created_by: uuid + }); + await window.__repos.combos.setExercises(tabataComboId, [ + { exerciseId: ex1, position: 0, defaultReps: 0, defaultWeight: 0 }, + { exerciseId: ex2, position: 1, defaultReps: 0, defaultWeight: 0 } + ]); + const tabataSessId = await window.__repos.sessions.create({ + title: 'TABATA Session', description: '', created_by: uuid + }); + await window.__repos.sessions.setItems(tabataSessId, [ + { item_id: tabataComboId, item_type: 'combo', repetitions: 4, weight: 0.0 } + ]); + + return { emomSessId, tabataSessId }; +} +""" + +_COUNT_SCRIPT = """ +async () => (await window.__repos.exercises.getAll()).length +""" + + +def wait_for_db(page: Page, url: str) -> None: + page.goto(url) + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + +def ensure_language(page: Page, lang: str) -> None: + current = page.evaluate("async () => window.__repos.settings.get('language')") + if current != lang: + page.evaluate(f"async () => window.__repos.settings.set('language', '{lang}')") + page.reload() + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + +def has_data(page: Page) -> bool: + return page.evaluate(_COUNT_SCRIPT) > 0 + + +# --------------------------------------------------------------------------- +# Screenshot helpers +# --------------------------------------------------------------------------- + +def shot(page: Page, out_dir: Path, name: str, full_page: bool = True) -> None: + time.sleep(0.3) + path = out_dir / f"{name}.png" + page.screenshot(path=str(path), full_page=full_page) + print(f" ✓ {path.name}") + + +def goto(page: Page, url: str, route: str, wait_selector: str | None = None) -> None: + page.goto(f"{url}/#/{route}", wait_until="domcontentloaded") + if wait_selector: + page.wait_for_selector(wait_selector, timeout=8000) + else: + time.sleep(0.3) + + +# --------------------------------------------------------------------------- +# Main capture flow +# --------------------------------------------------------------------------- + +def capture(page: Page, url: str, out_dir: Path, lang: str) -> None: + wait_for_db(page, url) + ensure_language(page, lang) + + if not has_data(page): + print(" Seeding database…") + ids = page.evaluate(_SEED_SCRIPT) + print(" Seed complete.") + else: + raise RuntimeError("DB already has data — start with a fresh browser context") + + # ── EMOM — work phase starts immediately ─────────────────────────────── + goto(page, url, f"sessions/{ids['emomSessId']}/execute", '[data-testid="execute-timer"]') + shot(page, out_dir, "combo-emom") + + # ── TABATA — work phase ───────────────────────────────────────────── + # Navigate away first so Vue Router remounts SessionExecuteView (it reuses + # the component when only params change, see all_pages.py for the same + # workaround). + goto(page, url, "sessions", '[data-testid="session-new-btn"]') + goto(page, url, f"sessions/{ids['tabataSessId']}/execute", '[data-testid="execute-timer"]') + shot(page, out_dir, "combo-tabata-work") + + # ── TABATA — rest phase (work duration is 6s in the seed above) ──────── + page.wait_for_selector('[data-testid="execute-tabata-rest"]', timeout=12000) + shot(page, out_dir, "combo-tabata-rest") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--url", default=os.environ.get("APP_URL", "http://localhost:5199")) + parser.add_argument("--browser", default="firefox", choices=["firefox", "chromium", "webkit"]) + parser.add_argument("--lang", default="en", choices=["en", "fr"]) + parser.add_argument("--width", type=int, default=1920) + parser.add_argument("--height", type=int, default=1080) + parser.add_argument("--out-dir", default=None) + return parser.parse_args(argv) + + +def default_out_dir(browser: str, lang: str, width: int, height: int) -> Path: + project_root = Path(__file__).resolve().parents[2] + prefix = f"combo-timers-{browser}" if lang == "en" else f"combo-timers-{lang}-{browser}" + return project_root / "tmp" / "screenshots" / f"{prefix}-{width}x{height}" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + out_dir = ( + Path(args.out_dir) + if args.out_dir + else default_out_dir(args.browser, args.lang, args.width, args.height) + ) + out_dir.mkdir(parents=True, exist_ok=True) + + print(f"\n── {args.browser} {args.width}×{args.height} → {out_dir}") + + with sync_playwright() as p: + browser_type = getattr(p, args.browser) + browser = browser_type.launch(headless=True) + try: + context = browser.new_context(viewport={"width": args.width, "height": args.height}) + page = context.new_page() + page.on("dialog", lambda dialog: dialog.accept()) + capture(page, args.url, out_dir, args.lang) + count = len(list(out_dir.glob("*.png"))) + print(f"\n Done — {count} screenshots saved") + except Exception as exc: + print(f"\n ERROR: {exc}", file=sys.stderr) + traceback.print_exc() + return 1 + finally: + browser.close() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/screenshots/exercise_list.py b/scripts/screenshots/exercise_list.py new file mode 100644 index 0000000..cda2489 --- /dev/null +++ b/scripts/screenshots/exercise_list.py @@ -0,0 +1,130 @@ +""" +Screenshot the Exercise List page at a configurable viewport. + +Intended for visual investigations (e.g., layout discussions, documentation +figures). Not a test — no assertions; see `tests/` for the test suite. + +Prerequisites: + - Playwright for Python installed (see `tests/requirements.txt`) + - Playwright browsers installed: `python -m playwright install firefox chromium` + +Typical usage (from project root): + + # 1. Start the Vite dev server in one terminal + npm run dev -- --port 5199 --strictPort + + # 2. In another terminal, run the script + python scripts/screenshots/exercise_list.py + +Options (all optional, override via CLI flags): + + --url URL App URL (default: http://localhost:5199) + --browser NAME firefox | chromium | webkit (default: firefox) + --width N Viewport width in px (default: 1920) + --height N Viewport height in px (default: 1080) + --out PATH Output file path (default: tmp/exercise-list--x.png) + --no-seed Do not create sample exercises (use the existing DB as-is) + +Exit code: 0 on success, 1 on failure. +""" + +from __future__ import annotations + +import argparse +import os +import sys +import time +from pathlib import Path + +from playwright.sync_api import sync_playwright + + +SAMPLE_EXERCISES = [ + {"title": "Push-ups", "desc": "Classic bodyweight push-up", "reps": "15"}, + {"title": "Squats", "desc": "Bodyweight squat", "reps": "20"}, + {"title": "Plank", "desc": "Core stability hold", "reps": "60"}, +] + + +def ensure_sample_exercises(page, url: str) -> None: + """Create a few exercises via the UI so the list has visible cards. + + No-op if the list already contains at least one card. + """ + page.goto(f"{url}/#/exercises", wait_until="networkidle") + cards = page.locator('[data-testid^="exercise-card-"]') + if cards.count() > 0: + return + + for ex in SAMPLE_EXERCISES: + page.goto(f"{url}/#/exercises/new", wait_until="networkidle") + page.wait_for_selector('[data-testid="exercise-title"]', timeout=10000) + page.locator('[data-testid="exercise-title"]').fill(ex["title"]) + page.locator('[data-testid="exercise-description"]').fill(ex["desc"]) + page.locator('[data-testid="exercise-reps"]').fill(ex["reps"]) + page.locator('[data-testid="exercise-save"]').click() + # After save the app navigates to the detail page; just wait for URL + # to move away from `/new`. + page.wait_for_function( + "() => !location.hash.endsWith('/exercises/new')", + timeout=5000, + ) + + +def parse_args(argv: list[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--url", default=os.environ.get("APP_URL", "http://localhost:5199")) + parser.add_argument("--browser", default="firefox", choices=["firefox", "chromium", "webkit"]) + parser.add_argument("--width", type=int, default=1920) + parser.add_argument("--height", type=int, default=1080) + parser.add_argument("--out", default=None, help="Output file path") + parser.add_argument("--no-seed", action="store_true", help="Skip creating sample exercises") + return parser.parse_args(argv) + + +def default_out_path(browser: str, width: int, height: int) -> Path: + """Default output goes to tmp/ at the project root (gitignored).""" + project_root = Path(__file__).resolve().parents[2] + tmp_dir = project_root / "tmp" + tmp_dir.mkdir(parents=True, exist_ok=True) + return tmp_dir / f"exercise-list-{browser}-{width}x{height}.png" + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv or sys.argv[1:]) + out_path = Path(args.out) if args.out else default_out_path(args.browser, args.width, args.height) + out_path.parent.mkdir(parents=True, exist_ok=True) + + viewport = {"width": args.width, "height": args.height} + + with sync_playwright() as p: + browser_type = getattr(p, args.browser) + browser = browser_type.launch(headless=True) + try: + context = browser.new_context(viewport=viewport) + page = context.new_page() + + page.goto(args.url, wait_until="networkidle") + # Wait for the app to finish DB init overlay, if any. + try: + page.wait_for_selector(".db-overlay", state="detached", timeout=10000) + except Exception: + pass + + if not args.no_seed: + ensure_sample_exercises(page, args.url) + + page.goto(f"{args.url}/#/exercises", wait_until="networkidle") + page.wait_for_selector('[data-testid^="exercise-card-"]', timeout=5000) + # Let layout settle before capturing. + time.sleep(0.5) + + page.screenshot(path=str(out_path), full_page=True) + print(f"Saved screenshot: {out_path}") + return 0 + finally: + browser.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/screenshots/overview_shots.py b/scripts/screenshots/overview_shots.py new file mode 100644 index 0000000..4f5463c --- /dev/null +++ b/scripts/screenshots/overview_shots.py @@ -0,0 +1,82 @@ +"""One-off: capture the Exercises list screen (desktop + mobile, en + fr) +to replace the home-page overview screenshots in demarrage-rapide docs.""" + +from __future__ import annotations + +import time +from pathlib import Path + +from playwright.sync_api import sync_playwright + +URL = "http://localhost:5199" +OUT_DIR = Path("/tmp/overview-shots") +OUT_DIR.mkdir(parents=True, exist_ok=True) + +SEED_SCRIPT = """ +async () => { + const uuid = await window.__repos.settings.get('user_uuid'); + await window.__repos.exercises.create({ + title: 'Push-ups', + description: 'Classic bodyweight push-up.', + asymmetric: false, alternate: false, image_urls: [], video_urls: [], + default_reps: 15, default_weight: 0, created_by: uuid + }); + await window.__repos.exercises.create({ + title: 'Squats', + description: 'Bodyweight squat — great for legs and glutes.', + asymmetric: false, alternate: false, image_urls: [], video_urls: [], + default_reps: 20, default_weight: 0, created_by: uuid + }); + await window.__repos.exercises.create({ + title: 'Deadlift', + description: 'Barbell deadlift — compound posterior-chain movement.', + asymmetric: false, alternate: false, image_urls: [], video_urls: [], + default_reps: 5, default_weight: 100, created_by: uuid + }); +} +""" + + +def wait_for_db(page): + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + +def capture(browser, lang: str, width: int, height: int, name: str): + context = browser.new_context(viewport={"width": width, "height": height}) + page = context.new_page() + page.goto(URL) + wait_for_db(page) + page.evaluate(f"async () => window.__repos.settings.set('language', '{lang}')") + page.reload() + wait_for_db(page) + + count = page.evaluate("async () => (await window.__repos.exercises.getAll()).length") + if count == 0: + page.evaluate(SEED_SCRIPT) + + page.goto(f"{URL}/#/exercises") + page.wait_for_selector('[data-testid="exercise-new-btn"]', timeout=8000) + time.sleep(0.5) + out_path = OUT_DIR / f"{name}.{lang}.png" + page.screenshot(path=str(out_path), full_page=True) + print(f"saved {out_path}") + context.close() + + +def main(): + with sync_playwright() as p: + browser = p.firefox.launch(headless=True) + try: + capture(browser, "en", 1920, 1080, "overview-desktop") + capture(browser, "fr", 1920, 1080, "overview-desktop") + capture(browser, "en", 390, 844, "overview-mobile") + capture(browser, "fr", 390, 844, "overview-mobile") + finally: + browser.close() + + +if __name__ == "__main__": + main() diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..57e5875 --- /dev/null +++ b/src/App.vue @@ -0,0 +1,313 @@ + + + + + diff --git a/src/assets/license.md b/src/assets/license.md new file mode 100644 index 0000000..bc16720 --- /dev/null +++ b/src/assets/license.md @@ -0,0 +1,660 @@ +# GNU AFFERO GENERAL PUBLIC LICENSE + +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + +## Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains +free software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing +under this license. + +The precise terms and conditions for copying, distribution and +modification follow. + +## TERMS AND CONDITIONS + +### 0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public +License. + +"Copyright" also means copyright-like laws that apply to other kinds +of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of +an exact copy. The resulting work is called a "modified version" of +the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user +through a computer network, with no transfer of a copy, is not +conveying. + +An interactive user interface displays "Appropriate Legal Notices" to +the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +### 1. Source Code. + +The "source code" for a work means the preferred form of the work for +making modifications to it. "Object code" means any non-source form of +a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can +regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same +work. + +### 2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, +without conditions so long as your license otherwise remains in force. +You may convey covered works to others for the sole purpose of having +them make modifications exclusively for you, or provide you with +facilities for running those works, provided that you comply with the +terms of this License in conveying all material for which you do not +control copyright. Those thus making or running the covered works for +you must do so exclusively on your behalf, under your direction and +control, on terms that prohibit them from making any copies of your +copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the +conditions stated below. Sublicensing is not allowed; section 10 makes +it unnecessary. + +### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such +circumvention is effected by exercising rights under this License with +respect to the covered work, and you disclaim any intention to limit +operation or modification of the work as a means of enforcing, against +the work's users, your or third parties' legal rights to forbid +circumvention of technological measures. + +### 4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +### 5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these +conditions: + +- a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. +- b) The work must carry prominent notices stating that it is + released under this License and any conditions added under + section 7. This requirement modifies the requirement in section 4 + to "keep intact all notices". +- c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. +- d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +### 6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms of +sections 4 and 5, provided that you also convey the machine-readable +Corresponding Source under the terms of this License, in one of these +ways: + +- a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. +- b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the Corresponding + Source from a network server at no charge. +- c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. +- d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. +- e) Convey the object code using peer-to-peer transmission, + provided you inform other peers where the object code and + Corresponding Source of the work are being offered to the general + public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, +family, or household purposes, or (2) anything designed or sold for +incorporation into a dwelling. In determining whether a product is a +consumer product, doubtful cases shall be resolved in favor of +coverage. For a particular product received by a particular user, +"normally used" refers to a typical or common use of that class of +product, regardless of the status of the particular user or of the way +in which the particular user actually uses, or expects or is expected +to use, the product. A product is a consumer product regardless of +whether the product has substantial commercial, industrial or +non-consumer uses, unless such uses represent the only significant +mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to +install and execute modified versions of a covered work in that User +Product from a modified version of its Corresponding Source. The +information must suffice to ensure that the continued functioning of +the modified object code is in no case prevented or interfered with +solely because modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or +updates for a work that has been modified or installed by the +recipient, or for the User Product in which it has been modified or +installed. Access to a network may be denied when the modification +itself materially and adversely affects the operation of the network +or violates the rules and protocols for communication across the +network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +### 7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders +of that material) supplement the terms of this License with terms: + +- a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or +- b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or +- c) Prohibiting misrepresentation of the origin of that material, + or requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or +- d) Limiting the use for publicity purposes of names of licensors + or authors of the material; or +- e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or +- f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions + of it) with contractual assumptions of liability to the recipient, + for any liability that these contractual assumptions directly + impose on those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; the +above requirements apply either way. + +### 8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your license +from a particular copyright holder is reinstated (a) provisionally, +unless and until the copyright holder explicitly and finally +terminates your license, and (b) permanently, if the copyright holder +fails to notify you of the violation by some reasonable means prior to +60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +### 9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run +a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +### 10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +### 11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned +or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within the +scope of its coverage, prohibits the exercise of, or is conditioned on +the non-exercise of one or more of the rights that are specifically +granted under this License. You may not convey a covered work if you +are a party to an arrangement with a third party that is in the +business of distributing software, under which you make payment to the +third party based on the extent of your activity of conveying the +work, and under which the third party grants, to any of the parties +who would receive the covered work from you, a discriminatory patent +license (a) in connection with copies of the covered work conveyed by +you (or copies made from those copies), or (b) primarily for and in +connection with specific products or compilations that contain the +covered work, unless you entered into that arrangement, or that patent +license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +### 12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under +this License and any other pertinent obligations, then as a +consequence you may not convey it at all. For example, if you agree to +terms that obligate you to collect a royalty for further conveying +from those to whom you convey the Program, the only way you could +satisfy both those terms and this License would be to refrain entirely +from conveying the Program. + +### 13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your +version supports such interaction) an opportunity to receive the +Corresponding Source of your version by providing access to the +Corresponding Source from a network server at no charge, through some +standard or customary means of facilitating copying of software. This +Corresponding Source shall include the Corresponding Source for any +work covered by version 3 of the GNU General Public License that is +incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +### 14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions +of the GNU Affero General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever +published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions +of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +### 15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT +WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND +PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE +DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR +CORRECTION. + +### 16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR +CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES +ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT +NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR +LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM +TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER +PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +### 17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + +## How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these +terms. + +To do so, attach the following notices to the program. It is safest to +attach them to the start of each source file to most effectively state +the exclusion of warranty; and each file should have at least the +"copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as + published by the Free Software Foundation, either version 3 of the + License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper +mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for +the specific requirements. + +You should also get your employer (if you work as a programmer) or +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. For more information on this, and how to apply and follow +the GNU AGPL, see . diff --git a/src/assets/notice.md b/src/assets/notice.md new file mode 100644 index 0000000..30532be --- /dev/null +++ b/src/assets/notice.md @@ -0,0 +1,15 @@ +**TrainUs — an open, local-first, offline-capable fitness tracking application where you can create and share your own training sessions.** +Copyright (C) 2026 [David Florance](http://kaivalya.eu) + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . diff --git a/src/components/AboutDialog.vue b/src/components/AboutDialog.vue new file mode 100644 index 0000000..82e4985 --- /dev/null +++ b/src/components/AboutDialog.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/src/components/BackupReminder.vue b/src/components/BackupReminder.vue new file mode 100644 index 0000000..b6ffb99 --- /dev/null +++ b/src/components/BackupReminder.vue @@ -0,0 +1,117 @@ + + + + + diff --git a/src/components/CleanJournalDialog.vue b/src/components/CleanJournalDialog.vue new file mode 100644 index 0000000..58c5a6c --- /dev/null +++ b/src/components/CleanJournalDialog.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/src/components/CreditsDialog.vue b/src/components/CreditsDialog.vue new file mode 100644 index 0000000..3e294b7 --- /dev/null +++ b/src/components/CreditsDialog.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/components/ExportFilterDialog.vue b/src/components/ExportFilterDialog.vue new file mode 100644 index 0000000..882706d --- /dev/null +++ b/src/components/ExportFilterDialog.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/src/components/ImportDialog.vue b/src/components/ImportDialog.vue new file mode 100644 index 0000000..8e0cb02 --- /dev/null +++ b/src/components/ImportDialog.vue @@ -0,0 +1,862 @@ + + + + + diff --git a/src/components/InstallPrompt.vue b/src/components/InstallPrompt.vue new file mode 100644 index 0000000..217d5b9 --- /dev/null +++ b/src/components/InstallPrompt.vue @@ -0,0 +1,65 @@ + + + + + diff --git a/src/components/LicenseDialog.vue b/src/components/LicenseDialog.vue new file mode 100644 index 0000000..491c6b2 --- /dev/null +++ b/src/components/LicenseDialog.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/src/components/ModalDialog.vue b/src/components/ModalDialog.vue new file mode 100644 index 0000000..c8a85d5 --- /dev/null +++ b/src/components/ModalDialog.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/src/components/SaveFileDialog.vue b/src/components/SaveFileDialog.vue new file mode 100644 index 0000000..b32188b --- /dev/null +++ b/src/components/SaveFileDialog.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/src/components/TimerDisplay.vue b/src/components/TimerDisplay.vue new file mode 100644 index 0000000..852b608 --- /dev/null +++ b/src/components/TimerDisplay.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/composables/useAudioCues.js b/src/composables/useAudioCues.js new file mode 100644 index 0000000..9470806 --- /dev/null +++ b/src/composables/useAudioCues.js @@ -0,0 +1,33 @@ +let audioCtx = null + +function getCtx() { + if (!audioCtx) audioCtx = new AudioContext() + return audioCtx +} + +function _beep(freq, duration, gain) { + try { + const ctx = getCtx() + if (ctx.state === 'suspended') ctx.resume() + const osc = ctx.createOscillator() + const gainNode = ctx.createGain() + osc.connect(gainNode) + gainNode.connect(ctx.destination) + osc.frequency.value = freq + gainNode.gain.value = gain + osc.start() + osc.stop(ctx.currentTime + duration) + } catch (_e) { + // Ignore audio errors — beep is best-effort + } +} + +export function useAudioCues() { + function playBip(volume = 1.0) { + _beep(440, 0.1, 0.6 * volume) + } + function playBIP(volume = 1.0) { + _beep(880, 0.3, 1.0 * volume) + } + return { playBip, playBIP } +} diff --git a/src/composables/useBackupDownload.js b/src/composables/useBackupDownload.js new file mode 100644 index 0000000..00fff40 --- /dev/null +++ b/src/composables/useBackupDownload.js @@ -0,0 +1,31 @@ +import { db } from '../db/database.js' +import { formatBackupFilename } from '../utils/backupFilename.js' +import { useBackupStatus } from './useBackupStatus.js' +import { useSaveFilePrompt } from './useSaveFilePrompt.js' + +export function useBackupDownload() { + const { markBackedUp } = useBackupStatus() + const { promptFilename } = useSaveFilePrompt() + + /** + * Prompts for a filename, exports the database, triggers a file download, + * and records last_backup_at. Returns false without side effects if cancelled. + */ + async function downloadBackup() { + const filename = await promptFilename(formatBackupFilename(__APP_VERSION__)) + if (!filename) return false + + const bytes = await db.exportDatabase() + const blob = new Blob([bytes], { type: 'application/x-sqlite3' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + await markBackedUp() + return true + } + + return { downloadBackup } +} diff --git a/src/composables/useBackupStatus.js b/src/composables/useBackupStatus.js new file mode 100644 index 0000000..e47490b --- /dev/null +++ b/src/composables/useBackupStatus.js @@ -0,0 +1,81 @@ +import { ref, computed } from 'vue' +import { useSettings } from './useSettings.js' + +const DEFAULT_REMINDER_DAYS = 7 + +const lastBackupAt = ref(null) +const dbCreatedAt = ref(null) +const backupReminderDays = ref(DEFAULT_REMINDER_DAYS) +const sessionReminderShown = ref(false) + +export function useBackupStatus() { + const { get, set } = useSettings() + + async function load() { + const lastBackupAtStr = await get('last_backup_at') + if (lastBackupAtStr) { + lastBackupAt.value = new Date(lastBackupAtStr) + } else { + lastBackupAt.value = null + } + + const dbCreatedAtStr = await get('db_created_at') + if (dbCreatedAtStr) { + dbCreatedAt.value = new Date(dbCreatedAtStr) + } else { + dbCreatedAt.value = null + } + + const daysStr = await get('backup_reminder_days') + const parsed = parseInt(daysStr, 10) + if (!isNaN(parsed) && parsed >= 1 && parsed <= 365) { + backupReminderDays.value = parsed + } else { + if (daysStr !== null && daysStr !== undefined) { + console.warn( + `[useBackupStatus] Invalid backup_reminder_days value: "${daysStr}", falling back to ${DEFAULT_REMINDER_DAYS}`, + ) + } + backupReminderDays.value = DEFAULT_REMINDER_DAYS + } + } + + const daysSinceBackup = computed(() => { + const effectiveDate = lastBackupAt.value || dbCreatedAt.value + if (!effectiveDate) return null + const now = new Date() + const diffMs = now.getTime() - effectiveDate.getTime() + return Math.floor(diffMs / (1000 * 60 * 60 * 24)) + }) + + const isStale = computed(() => { + const effectiveDate = lastBackupAt.value || dbCreatedAt.value + if (!effectiveDate) return true + const now = new Date() + const diffMs = now.getTime() - effectiveDate.getTime() + const thresholdMs = backupReminderDays.value * 86_400_000 + return diffMs > thresholdMs + }) + + const visible = computed(() => { + return isStale.value + }) + + async function markBackedUp() { + const now = new Date().toISOString() + await set('last_backup_at', now) + lastBackupAt.value = new Date(now) + } + + return { + lastBackupAt, + dbCreatedAt, + daysSinceBackup, + isStale, + visible, + load, + markBackedUp, + backupReminderDays, + sessionReminderShown, + } +} diff --git a/src/composables/useCollections.js b/src/composables/useCollections.js new file mode 100644 index 0000000..2679f3d --- /dev/null +++ b/src/composables/useCollections.js @@ -0,0 +1,54 @@ +import { ref } from 'vue' +import { collectionRepository } from '../db/repositories/collectionRepository.js' + +export function useCollections() { + const collections = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll() { + loading.value = true + error.value = null + try { + collections.value = await collectionRepository.getAll() + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function create(data) { + const id = await collectionRepository.create(data) + await fetchAll() + return id + } + + async function update(id, data) { + await collectionRepository.update(id, data) + await fetchAll() + } + + async function remove(id) { + await collectionRepository.delete(id) + await fetchAll() + } + + async function search(query) { + loading.value = true + error.value = null + try { + collections.value = await collectionRepository.search(query) + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function getById(id) { + return collectionRepository.getById(id) + } + + return { collections, loading, error, fetchAll, create, update, remove, search, getById } +} diff --git a/src/composables/useCombos.js b/src/composables/useCombos.js new file mode 100644 index 0000000..dcdcbc7 --- /dev/null +++ b/src/composables/useCombos.js @@ -0,0 +1,54 @@ +import { ref } from 'vue' +import { comboRepository } from '../db/repositories/comboRepository.js' + +export function useCombos() { + const combos = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll() { + loading.value = true + error.value = null + try { + combos.value = await comboRepository.getAll() + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function create(data) { + const id = await comboRepository.create(data) + await fetchAll() + return id + } + + async function update(id, data) { + await comboRepository.update(id, data) + await fetchAll() + } + + async function remove(id) { + await comboRepository.delete(id) + await fetchAll() + } + + async function search(query) { + loading.value = true + error.value = null + try { + combos.value = await comboRepository.search(query) + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function getById(id) { + return comboRepository.getById(id) + } + + return { combos, loading, error, fetchAll, create, update, remove, search, getById } +} diff --git a/src/composables/useDatabase.js b/src/composables/useDatabase.js new file mode 100644 index 0000000..ca38749 --- /dev/null +++ b/src/composables/useDatabase.js @@ -0,0 +1,42 @@ +import { ref, readonly } from 'vue' +import { db } from '../db/database.js' + +const initialized = ref(false) +const initializing = ref(false) +const dbInfo = ref(null) +const error = ref(null) +const migrationPending = ref(null) + +export function useDatabase() { + async function init() { + if (initialized.value || initializing.value) return dbInfo.value + initializing.value = true + try { + dbInfo.value = await db.init() + migrationPending.value = db.migrationPending + initialized.value = !db.migrationPending + return dbInfo.value + } catch (e) { + error.value = e.message + throw e + } finally { + initializing.value = false + } + } + + async function runPendingMigrations() { + await db.runPendingMigrations() + migrationPending.value = null + initialized.value = true + } + + return { + initialized: readonly(initialized), + initializing: readonly(initializing), + dbInfo: readonly(dbInfo), + error: readonly(error), + migrationPending: readonly(migrationPending), + init, + runPendingMigrations, + } +} diff --git a/src/composables/useExecutionLogs.js b/src/composables/useExecutionLogs.js new file mode 100644 index 0000000..452475d --- /dev/null +++ b/src/composables/useExecutionLogs.js @@ -0,0 +1,69 @@ +import { ref } from 'vue' +import { executionLogRepository } from '../db/repositories/executionLogRepository.js' + +export function useExecutionLogs() { + const logs = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll() { + loading.value = true + error.value = null + try { + logs.value = await executionLogRepository.getAll() + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function fetchBySession(sessionId) { + loading.value = true + error.value = null + try { + logs.value = await executionLogRepository.getBySessionId(sessionId) + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function create(data) { + const id = await executionLogRepository.create(data) + await fetchAll() + return id + } + + async function remove(id) { + await executionLogRepository.delete(id) + await fetchAll() + } + + async function getById(id) { + return executionLogRepository.getById(id) + } + + async function getCountByDateRange(from, to) { + return executionLogRepository.getCountByDateRange(from, to) + } + + async function removeByDateRange(from, to) { + await executionLogRepository.deleteByDateRange(from, to) + await fetchAll() + } + + return { + logs, + loading, + error, + fetchAll, + fetchBySession, + create, + remove, + getById, + getCountByDateRange, + removeByDateRange, + } +} diff --git a/src/composables/useExercises.js b/src/composables/useExercises.js new file mode 100644 index 0000000..57797d0 --- /dev/null +++ b/src/composables/useExercises.js @@ -0,0 +1,54 @@ +import { ref } from 'vue' +import { exerciseRepository } from '../db/repositories/exerciseRepository.js' + +export function useExercises() { + const exercises = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll() { + loading.value = true + error.value = null + try { + exercises.value = await exerciseRepository.getAll() + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function create(data) { + const id = await exerciseRepository.create(data) + await fetchAll() + return id + } + + async function update(id, data) { + await exerciseRepository.update(id, data) + await fetchAll() + } + + async function remove(id) { + await exerciseRepository.delete(id) + await fetchAll() + } + + async function search(query) { + loading.value = true + error.value = null + try { + exercises.value = await exerciseRepository.search(query) + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function getById(id) { + return exerciseRepository.getById(id) + } + + return { exercises, loading, error, fetchAll, create, update, remove, search, getById } +} diff --git a/src/composables/useHtmlExport.js b/src/composables/useHtmlExport.js new file mode 100644 index 0000000..21c2d47 --- /dev/null +++ b/src/composables/useHtmlExport.js @@ -0,0 +1,268 @@ +import i18next from 'i18next' +import { formatDate, formatDuration, formatItemDuration } from '../utils/dateFormatter.js' +import { useSaveFilePrompt } from './useSaveFilePrompt.js' + +function escHtml(str) { + return String(str ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} + +function enrichItemsWithTiming(log) { + const items = log.items || [] + return items.map((item, i) => { + const curr = item.timestamp ? new Date(item.timestamp).getTime() : null + const prev = + i === 0 + ? log.started_at + ? new Date(log.started_at).getTime() + : null + : items[i - 1].timestamp + ? new Date(items[i - 1].timestamp).getTime() + : null + return { ...item, duration_ms: curr && prev ? curr - prev : null } + }) +} + +function groupItems(items) { + if (!items || items.length === 0) return [] + const result = [] + const comboMap = {} + for (const item of items) { + if (!item.combo_id) { + result.push({ type: 'exercise', item }) + } else { + if (!comboMap[item.combo_id]) { + comboMap[item.combo_id] = { + type: 'combo', + combo_id: item.combo_id, + combo_title: item.combo_title, + exercises: [], + } + result.push(comboMap[item.combo_id]) + } + comboMap[item.combo_id].exercises.push(item) + } + } + return result +} + +function comboRounds(group) { + const hasRounds = group.exercises.some((ex) => ex.round_number != null) + if (!hasRounds) return [group.exercises] + const map = {} + for (const ex of group.exercises) { + const rn = ex.round_number ?? 0 + if (!map[rn]) map[rn] = [] + map[rn].push(ex) + } + return Object.entries(map) + .sort(([a], [b]) => Number(a) - Number(b)) + .map(([, exes]) => exes) +} + +function itemParens(item) { + const parts = [item.reps_done, item.weight_used] + const dur = formatItemDuration(item.duration_ms) + if (dur) parts.push(dur) + return `(${parts.join(',')})` +} + +function renderSessionItemsHtml(log) { + const groups = groupItems(enrichItemsWithTiming(log)) + if (!groups.length) return '' + return groups + .map((group, gi) => { + const sep = gi > 0 ? ', ' : '' + if (group.type === 'exercise') { + return `${sep}${escHtml(group.item.exercise_title)} ${escHtml(itemParens(group.item))}` + } + const rounds = comboRounds(group) + const roundsHtml = rounds + .map((round, ri) => { + const exsHtml = round + .map((ex, ei) => { + const sep2 = ri > 0 || ei > 0 ? ', ' : '' + return `${sep2}${escHtml(ex.exercise_title)} ${escHtml(itemParens(ex))}` + }) + .join('') + return ri > 0 ? ` // ${exsHtml}` : exsHtml + }) + .join('') + return `${sep}${escHtml(group.combo_title)} ×${rounds.length} : {${roundsHtml}}` + }) + .join('') +} + +async function fetchIconAsBase64() { + try { + const res = await fetch('/icon.svg') + if (!res.ok) return null + const text = await res.text() + const bytes = new TextEncoder().encode(text) + const binStr = Array.from(bytes, (b) => String.fromCharCode(b)).join('') + return `data:image/svg+xml;base64,${btoa(binStr)}` + } catch { + return null + } +} + +async function downloadHtml(defaultFilename, html) { + const { promptFilename } = useSaveFilePrompt() + const filename = await promptFilename(defaultFilename) + if (!filename) return + + const blob = new Blob([html], { type: 'text/html' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +export function useHtmlExport() { + async function exportHomeAsHtml({ + count7Days, + count30Days, + recentSessions, + historyByDay, + historyFrom, + historyTo, + }) { + const lang = i18next.language || 'en' + const t = i18next.t.bind(i18next) + const iconDataUrl = await fetchIconAsBase64() + const exportDate = formatDate(new Date(), lang) + + const iconHtml = iconDataUrl + ? `TrainUs` + : '' + + const recentHtml = recentSessions + .map((log) => { + const duration = formatDuration(log.started_at, log.finished_at) + const date = formatDate(log.started_at, lang) + const itemsLine = renderSessionItemsHtml(log) + return ` +
+
+ ${escHtml(log.session_title)} + + ${escHtml(date)} + ${duration ? `⏱ ${escHtml(duration)}` : ''} + +
+ ${itemsLine ? `
${itemsLine}
` : ''} +
` + }) + .join('') + + const historyHtml = historyByDay + .map(({ day, logs }) => { + const dayLabel = formatDate(day, lang) + const logsHtml = logs + .map((log) => { + const duration = formatDuration(log.started_at, log.finished_at) + const itemsLine = renderSessionItemsHtml(log) + return ` +
+
+ ${escHtml(log.session_title)} + ${duration ? `⏱ ${escHtml(duration)}` : ''} +
+ ${itemsLine ? `
${itemsLine}
` : ''} +
` + }) + .join('') + return `

${escHtml(dayLabel)}

${logsHtml}
` + }) + .join('') + + const html = ` + + + + + TrainUs + + + +
+ ${iconHtml} + TrainUs +
+

${escHtml(t('home.exportedOn', { date: exportDate }))}

+ +
+
+ ${count7Days} + ${escHtml(t('home.sessionsLast7Days'))} +
+
+ ${count30Days} + ${escHtml(t('home.sessionsLast30Days'))} +
+
+ + ${ + recentSessions.length > 0 + ? `
+

${escHtml(t('home.recentSessions'))}

+ ${recentHtml} +
` + : '' + } + + ${ + historyByDay.length > 0 + ? `
+
+

${escHtml(t('home.history'))}

+ ${escHtml(historyFrom)} – ${escHtml(historyTo)} +
+ ${historyHtml} +
` + : '' + } + +` + + const date = new Date().toISOString().slice(0, 10) + await downloadHtml(`trainus-home-${date}.html`, html) + } + + return { exportHomeAsHtml } +} diff --git a/src/composables/useInstallPrompt.js b/src/composables/useInstallPrompt.js new file mode 100644 index 0000000..a53e6a5 --- /dev/null +++ b/src/composables/useInstallPrompt.js @@ -0,0 +1,29 @@ +import { ref } from 'vue' + +let deferredPrompt = null +const canInstall = ref(false) + +window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault() + deferredPrompt = e + canInstall.value = true +}) + +window.addEventListener('appinstalled', () => { + deferredPrompt = null + canInstall.value = false +}) + +export function useInstallPrompt() { + async function install() { + if (!deferredPrompt) return + deferredPrompt.prompt() + const { outcome } = await deferredPrompt.userChoice + if (outcome === 'accepted') { + deferredPrompt = null + canInstall.value = false + } + } + + return { canInstall, install } +} diff --git a/src/composables/useJsonExport.js b/src/composables/useJsonExport.js new file mode 100644 index 0000000..8733139 --- /dev/null +++ b/src/composables/useJsonExport.js @@ -0,0 +1,293 @@ +import { settingsRepository } from '../db/repositories/settingsRepository.js' +import { exerciseRepository } from '../db/repositories/exerciseRepository.js' +import { comboRepository } from '../db/repositories/comboRepository.js' +import { sessionRepository } from '../db/repositories/sessionRepository.js' +import { collectionRepository } from '../db/repositories/collectionRepository.js' +import { executionLogRepository } from '../db/repositories/executionLogRepository.js' +import { buildEnvelope, downloadJson } from '../utils/jsonEnvelope.js' + +const SETTINGS_EXPORT_KEYS = ['user_name', 'language', 'theme', 'backup_reminder_days'] + +function mergeById(existing = [], additional = []) { + const map = new Map(existing.map((item) => [item.id, item])) + for (const item of additional) { + if (!map.has(item.id)) map.set(item.id, item) + } + return [...map.values()] +} + +/** Exercises referenced by a combo's exercise list, fetched as full entities. */ +async function collectComboDependencies(combos) { + const exerciseIds = new Set() + for (const combo of combos) { + for (const ex of combo.exercises || []) { + exerciseIds.add(ex.exercise_id) + } + } + const exercises = ( + await Promise.all([...exerciseIds].map((id) => exerciseRepository.getById(id))) + ).filter(Boolean) + return { exercises } +} + +/** Combos and exercises referenced by a session's items, including the combos' own exercises. */ +async function collectSessionDependencies(sessions) { + const exerciseIds = new Set() + const comboIds = new Set() + for (const session of sessions) { + for (const item of session.items || []) { + if (item.item_type === 'exercise') exerciseIds.add(item.item_id) + if (item.item_type === 'combo') comboIds.add(item.item_id) + } + } + const combos = (await Promise.all([...comboIds].map((id) => comboRepository.getById(id)))).filter( + Boolean, + ) + const { exercises: comboExercises } = await collectComboDependencies(combos) + for (const exercise of comboExercises) { + exerciseIds.add(exercise.id) + } + const exercises = ( + await Promise.all([...exerciseIds].map((id) => exerciseRepository.getById(id))) + ).filter(Boolean) + return { combos, exercises } +} + +/** Sessions referenced by a collection, plus their transitive combo/exercise dependencies. */ +async function collectCollectionDependencies(collections) { + const sessionIds = new Set() + for (const collection of collections) { + for (const s of collection.sessions || []) { + sessionIds.add(s.session_id) + } + } + const sessions = ( + await Promise.all([...sessionIds].map((id) => sessionRepository.getById(id))) + ).filter(Boolean) + const { combos, exercises } = await collectSessionDependencies(sessions) + return { sessions, combos, exercises } +} + +/** + * Composable for exporting all entity types as JSON. + */ +export function useJsonExport() { + async function getExporter() { + const name = await settingsRepository.get('user_name') + const uuid = await settingsRepository.get('user_uuid') + return { name: name || 'User', uuid } + } + + async function exportAllExercises(query) { + const exercises = query + ? await exerciseRepository.search(query) + : await exerciseRepository.getAll() + const exporter = await getExporter() + const envelope = buildEnvelope({ exercises }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-exercises-${date}.json`, json) + } + + async function exportExercise(exerciseId) { + const exercise = await exerciseRepository.getById(exerciseId) + if (!exercise) throw new Error('Exercise not found') + const exporter = await getExporter() + const envelope = buildEnvelope({ exercises: [exercise] }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-exercises-${date}.json`, json) + } + + async function exportAllCombos(query) { + const combos = query + ? await comboRepository.searchWithExercises(query) + : await comboRepository.getAllWithExercises() + const { exercises } = await collectComboDependencies(combos) + const exporter = await getExporter() + const envelope = buildEnvelope({ combos, exercises }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-combos-${date}.json`, json) + } + + async function exportCombo(comboId) { + const combo = await comboRepository.getById(comboId) + if (!combo) throw new Error('Combo not found') + const { exercises } = await collectComboDependencies([combo]) + const exporter = await getExporter() + const envelope = buildEnvelope({ combos: [combo], exercises }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-combos-${date}.json`, json) + } + + async function exportAllSessions(query) { + const sessions = query + ? await sessionRepository.searchWithItems(query) + : await sessionRepository.getAllWithItems() + const { combos, exercises } = await collectSessionDependencies(sessions) + const exporter = await getExporter() + const envelope = buildEnvelope({ sessions, combos, exercises }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-sessions-${date}.json`, json) + } + + async function exportSession(sessionId) { + const session = await sessionRepository.getById(sessionId) + if (!session) throw new Error('Session not found') + const { combos, exercises } = await collectSessionDependencies([session]) + const exporter = await getExporter() + const envelope = buildEnvelope({ sessions: [session], combos, exercises }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-sessions-${date}.json`, json) + } + + async function exportAllCollections(query) { + const collections = query + ? await collectionRepository.searchWithSessions(query) + : await collectionRepository.getAllWithSessions() + const { sessions, combos, exercises } = await collectCollectionDependencies(collections) + const exporter = await getExporter() + const envelope = buildEnvelope({ collections, sessions, combos, exercises }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-collections-${date}.json`, json) + } + + async function exportCollection(collectionId) { + const collection = await collectionRepository.getById(collectionId) + if (!collection) throw new Error('Collection not found') + const { sessions, combos, exercises } = await collectCollectionDependencies([collection]) + const exporter = await getExporter() + const envelope = buildEnvelope( + { collections: [collection], sessions, combos, exercises }, + exporter, + ) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-collections-${date}.json`, json) + } + + async function exportAllExecutionLogs() { + const executionLogs = await executionLogRepository.getAllWithItems() + const exporter = await getExporter() + const envelope = buildEnvelope({ executionLogs }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-logs-${date}.json`, json) + } + + async function exportSettings() { + const allSettings = await settingsRepository.getAll() + const data = {} + for (const key of SETTINGS_EXPORT_KEYS) { + if (allSettings[key] !== undefined) { + data[key] = allSettings[key] + } + } + const exporter = await getExporter() + const envelope = buildEnvelope({ settings: data }, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-settings-${date}.json`, json) + } + + async function exportAll() { + const [exercises, combos, sessions, collections, executionLogs] = await Promise.all([ + exerciseRepository.getAll(), + comboRepository.getAllWithExercises(), + sessionRepository.getAllWithItems(), + collectionRepository.getAllWithSessions(), + executionLogRepository.getAllWithItems(), + ]) + + const allSettings = await settingsRepository.getAll() + const settingsData = {} + for (const key of SETTINGS_EXPORT_KEYS) { + if (allSettings[key] !== undefined) { + settingsData[key] = allSettings[key] + } + } + + const data = { exercises, combos, sessions, collections, executionLogs, settings: settingsData } + const exporter = await getExporter() + const envelope = buildEnvelope(data, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + await downloadJson(`trainus-full-${date}.json`, json) + } + + async function exportByFilter(options = {}) { + const data = {} + const exporter = await getExporter() + + if (options.exercises) { + data.exercises = await exerciseRepository.getAll() + } + if (options.combos) { + data.combos = await comboRepository.getAllWithExercises() + } + if (options.sessions) { + data.sessions = await sessionRepository.getAllWithItems() + } + if (options.collections) { + data.collections = await collectionRepository.getAllWithSessions() + } + + // Bundle dependencies so a partial selection (e.g. sessions without combos/exercises) + // still produces a self-contained, importable export. + if (data.collections) { + const deps = await collectCollectionDependencies(data.collections) + data.sessions = mergeById(data.sessions, deps.sessions) + data.combos = mergeById(data.combos, deps.combos) + data.exercises = mergeById(data.exercises, deps.exercises) + } + if (data.sessions) { + const deps = await collectSessionDependencies(data.sessions) + data.combos = mergeById(data.combos, deps.combos) + data.exercises = mergeById(data.exercises, deps.exercises) + } + if (data.combos) { + const deps = await collectComboDependencies(data.combos) + data.exercises = mergeById(data.exercises, deps.exercises) + } + + if (options.executionLogs) { + data.executionLogs = await executionLogRepository.getAllWithItems() + } + if (options.settings) { + const allSettings = await settingsRepository.getAll() + const settingsData = {} + for (const key of SETTINGS_EXPORT_KEYS) { + if (allSettings[key] !== undefined) { + settingsData[key] = allSettings[key] + } + } + data.settings = settingsData + } + + const envelope = buildEnvelope(data, exporter) + const json = JSON.stringify(envelope, null, 2) + const date = new Date().toISOString().slice(0, 10) + const filename = `trainus-data-${date}.json` + await downloadJson(filename, json) + } + + return { + exportAllExercises, + exportExercise, + exportAllCombos, + exportCombo, + exportAllSessions, + exportSession, + exportAllCollections, + exportCollection, + exportAllExecutionLogs, + exportSettings, + exportAll, + exportByFilter, + } +} diff --git a/src/composables/useJsonImport.js b/src/composables/useJsonImport.js new file mode 100644 index 0000000..a102a39 --- /dev/null +++ b/src/composables/useJsonImport.js @@ -0,0 +1,587 @@ +import { ref } from 'vue' +import { settingsRepository } from '../db/repositories/settingsRepository.js' +import { exerciseRepository } from '../db/repositories/exerciseRepository.js' +import { comboRepository } from '../db/repositories/comboRepository.js' +import { sessionRepository } from '../db/repositories/sessionRepository.js' +import { collectionRepository } from '../db/repositories/collectionRepository.js' +import { executionLogRepository } from '../db/repositories/executionLogRepository.js' +import { suffixRepository } from '../db/repositories/suffixRepository.js' +import { parseEnvelope, pickJsonFile } from '../utils/jsonEnvelope.js' + +export const COLLISION_ACTION = { + REPLACE: 'replace', + SKIP: 'skip', +} + +const SETTINGS_SKIP_KEYS = ['user_uuid', 'app_version', 'db_created_at', 'last_backup_at'] + +/** + * Composable for importing data from JSON files. + */ +export function useJsonImport() { + const importing = ref(false) + const error = ref(null) + + async function loadFile() { + const text = await pickJsonFile() + if (!text) return { envelope: null, error: null, cancelled: true } + + const result = parseEnvelope(text) + return { envelope: result.envelope, error: result.error, cancelled: false } + } + + async function ensureSuffix(uuid, name, suffix) { + const existing = await suffixRepository.getByUuid(uuid) + if (existing) return existing.suffix + + await suffixRepository.create({ user_uuid: uuid, user_name: name, suffix }) + return suffix + } + + // --- Full multi-entity import --- + + async function analyzeAll(envelope, { asMine = false } = {}) { + const localUuid = await settingsRepository.get('user_uuid') + const exporterUuid = envelope.metadata.exportedBy.uuid + const exporterName = envelope.metadata.exportedBy.name + const exportDate = envelope.metadata.exportDate + const isSameUser = asMine || exporterUuid === localUuid + + const result = { + summary: { + exporterName, + exporterUuid, + exportDate, + isSameUser, + asMine, + hasExercises: !!envelope.data.exercises?.length, + hasCombos: !!envelope.data.combos?.length, + hasSessions: !!envelope.data.sessions?.length, + hasCollections: !!envelope.data.collections?.length, + hasExecutionLogs: !!envelope.data.executionLogs?.length, + hasSettings: !!envelope.data.settings, + }, + exercises: null, + combos: null, + sessions: null, + collections: null, + executionLogs: null, + settings: null, + } + + // Analyze exercises + if (envelope.data.exercises?.length) { + result.exercises = await _analyzeEntityList( + envelope.data.exercises, + exerciseRepository, + 'title', + exporterUuid, + isSameUser, + ) + } + + // Analyze combos + if (envelope.data.combos?.length) { + result.combos = await _analyzeEntityList( + envelope.data.combos, + comboRepository, + 'title', + exporterUuid, + isSameUser, + ) + } + + // Analyze sessions + if (envelope.data.sessions?.length) { + result.sessions = await _analyzeEntityList( + envelope.data.sessions, + sessionRepository, + 'title', + exporterUuid, + isSameUser, + ) + } + + // Analyze collections + if (envelope.data.collections?.length) { + result.collections = await _analyzeEntityList( + envelope.data.collections, + collectionRepository, + 'label', + exporterUuid, + isSameUser, + ) + } + + // Analyze execution logs (no title collision, just ID existence) + if (envelope.data.executionLogs?.length) { + result.executionLogs = await _analyzeEntityList( + envelope.data.executionLogs, + executionLogRepository, + null, + exporterUuid, + isSameUser, + ) + } + + // Settings + if (envelope.data.settings) { + result.settings = envelope.data.settings + } + + // Suffix handling for different-user imports + if (!isSameUser) { + const suffixEntry = await suffixRepository.getByUuid(exporterUuid) + result.suffixInfo = { + needsSuffix: !suffixEntry, + existingSuffix: suffixEntry?.suffix || null, + } + } + + return result + } + + async function _analyzeEntityList(entities, repository, titleField, exporterUuid, isSameUser) { + const result = { + total: entities.length, + noCollision: [], + sameUserCollisions: [], + differentUser: [], + } + + // Same-user title conflicts (different id, same title) are detected once up front + // (O(n) instead of O(n²)) and folded into sameUserCollisions alongside id conflicts. + let titleMap = null + if (isSameUser && titleField) { + const allRows = await repository.getAll() + titleMap = new Map(allRows.map((row) => [row[titleField], row])) + } + + for (const entity of entities) { + const existing = await repository.getById(entity.id) + + if (isSameUser) { + if (existing) { + result.sameUserCollisions.push({ imported: entity, existing }) + } else { + const titleMatch = titleMap?.get(entity[titleField]) + if (titleMatch) { + result.sameUserCollisions.push({ imported: entity, existing: titleMatch }) + } else { + result.noCollision.push(entity) + } + } + } else { + if (existing && existing.created_by === exporterUuid) { + result.differentUser.push({ imported: entity, existing, action: 'update' }) + } else if (existing) { + result.differentUser.push({ imported: entity, existing, action: 'new_id' }) + } else { + result.differentUser.push({ imported: entity, existing: null, action: 'insert' }) + } + } + } + + return result + } + + async function applyAllImport(analysis, options = {}) { + const { + suffix, + exerciseResolutions = [], + comboResolutions = [], + sessionResolutions = [], + collectionResolutions = [], + } = options + + const localUuid = await settingsRepository.get('user_uuid') + const exporterUuid = analysis.summary.exporterUuid + const exporterName = analysis.summary.exporterName + const isSameUser = analysis.summary.isSameUser + + const idMap = { exercise: {}, combo: {}, session: {}, collection: {}, executionLog: {} } + const report = { + exercises: { imported: 0, skipped: 0, replaced: 0, updated: 0 }, + combos: { imported: 0, skipped: 0, replaced: 0, updated: 0 }, + sessions: { imported: 0, skipped: 0, replaced: 0, updated: 0 }, + collections: { imported: 0, skipped: 0, replaced: 0, updated: 0 }, + executionLogs: { imported: 0, skipped: 0 }, + settings: { imported: 0 }, + errors: [], + } + + // Register suffix if needed + if (!isSameUser && analysis.suffixInfo?.needsSuffix && suffix) { + await ensureSuffix(exporterUuid, exporterName, suffix) + } + + const effectiveSuffix = suffix || analysis.suffixInfo?.existingSuffix + + // 1. Import exercises + if (analysis.exercises) { + await _importEntityList( + analysis.exercises, + exerciseRepository, + 'title', + isSameUser, + localUuid, + exporterUuid, + effectiveSuffix, + exerciseResolutions, + idMap.exercise, + report.exercises, + report.errors, + ) + } + + // 2. Import combos (remap exercise_id references) + if (analysis.combos) { + await _importEntityList( + analysis.combos, + comboRepository, + 'title', + isSameUser, + localUuid, + exporterUuid, + effectiveSuffix, + comboResolutions, + idMap.combo, + report.combos, + report.errors, + { remapExercises: idMap.exercise }, + async (comboId, combo) => { + if (combo.exercises?.length) { + const exercises = combo.exercises.map((ex) => ({ + exerciseId: ex.exercise_id, + defaultReps: ex.default_reps ?? 1, + defaultWeight: ex.default_weight ?? 0, + })) + await comboRepository.setExercises(comboId, exercises) + } + }, + ) + } + + // 3. Import sessions (remap exercise_id and combo_id references) + if (analysis.sessions) { + await _importEntityList( + analysis.sessions, + sessionRepository, + 'title', + isSameUser, + localUuid, + exporterUuid, + effectiveSuffix, + sessionResolutions, + idMap.session, + report.sessions, + report.errors, + { remapExercises: idMap.exercise, remapCombos: idMap.combo }, + async (sessionId, session) => { + if (session.items?.length) { + const items = session.items.map((item) => ({ + item_id: item.item_id, + item_type: item.item_type, + repetitions: item.repetitions ?? 1, + weight: item.weight ?? 0, + })) + await sessionRepository.setItems(sessionId, items) + } + }, + ) + } + + // 4. Import collections (remap session_id references) + if (analysis.collections) { + await _importEntityList( + analysis.collections, + collectionRepository, + 'label', + isSameUser, + localUuid, + exporterUuid, + effectiveSuffix, + collectionResolutions, + idMap.collection, + report.collections, + report.errors, + { remapSessions: idMap.session }, + async (collectionId, collection) => { + if (collection.sessions?.length) { + const sessionIds = collection.sessions.map((s) => s.session_id) + await collectionRepository.setSessions(collectionId, sessionIds) + } + }, + ) + } + + // 5. Import execution logs (remap session_id and exercise_id) + if (analysis.executionLogs) { + await _importExecutionLogs( + analysis.executionLogs, + isSameUser, + localUuid, + exporterUuid, + idMap, + report.executionLogs, + report.errors, + ) + } + + // 6. Import settings — only for same-user restores; skip entirely for cross-user imports + if (analysis.settings && isSameUser) { + for (const [key, value] of Object.entries(analysis.settings)) { + if (!SETTINGS_SKIP_KEYS.includes(key)) { + await settingsRepository.set(key, value) + report.settings.imported++ + } + } + } else if (analysis.settings && !isSameUser) { + report.settings = { skipped: true } + } + + return report + } + + async function _importEntityList( + analysis, + repository, + titleField, + isSameUser, + localUuid, + exporterUuid, + suffix, + resolutions, + idMap, + report, + errors, + remapOptions = {}, + setNestedData = null, + ) { + const resolutionMap = {} + for (const r of resolutions) { + resolutionMap[r.imported.id] = r + } + + // Import no-collision entities (same-user path) + for (const entity of analysis.noCollision) { + try { + const finalEntity = _prepareEntity( + entity, + titleField, + isSameUser ? localUuid : exporterUuid, + suffix, + remapOptions, + ) + await repository.createWithId(entity.id, finalEntity) + idMap[entity.id] = entity.id + report.imported++ + if (setNestedData) { + try { + await setNestedData(entity.id, finalEntity) + } catch (ne) { + errors.push(ne.message) + } + } + } catch (e) { + errors.push(e.message) + } + } + + // Handle same-user collisions (id match or title match — both resolve against the + // existing row, so idMap correctly redirects same-batch references either way) + for (const collision of analysis.sameUserCollisions) { + const resolution = resolutionMap[collision.imported.id] + if (!resolution || resolution.action === COLLISION_ACTION.SKIP) { + report.skipped++ + idMap[collision.imported.id] = collision.existing.id + continue + } + + if (resolution.action === COLLISION_ACTION.REPLACE) { + try { + const finalEntity = _prepareEntity( + collision.imported, + titleField, + localUuid, + null, + remapOptions, + ) + await repository.replaceAll(collision.existing.id, finalEntity) + idMap[collision.imported.id] = collision.existing.id + report.replaced++ + if (setNestedData) { + try { + await setNestedData(collision.existing.id, finalEntity) + } catch (ne) { + errors.push(ne.message) + } + } + } catch (e) { + errors.push(e.message) + } + } + } + + // Handle different-user entities. A resolution here means a title conflict was found + // (on the suffixed title) against an existing row — redirect to it instead of the + // default id-based action. + for (const entry of analysis.differentUser) { + const resolution = resolutionMap[entry.imported.id] + try { + if (resolution) { + if (resolution.action === COLLISION_ACTION.SKIP) { + report.skipped++ + idMap[entry.imported.id] = resolution.existing.id + continue + } + + const finalEntity = _prepareEntity( + entry.imported, + titleField, + exporterUuid, + suffix, + remapOptions, + ) + await repository.replaceAll(resolution.existing.id, finalEntity) + idMap[entry.imported.id] = resolution.existing.id + report.replaced++ + if (setNestedData) { + try { + await setNestedData(resolution.existing.id, finalEntity) + } catch (ne) { + errors.push(ne.message) + } + } + continue + } + + const entityId = entry.action === 'new_id' ? crypto.randomUUID() : entry.imported.id + const finalEntity = _prepareEntity( + entry.imported, + titleField, + exporterUuid, + suffix, + remapOptions, + ) + + if (entry.action === 'update') { + await repository.replaceAll(entityId, finalEntity) + idMap[entry.imported.id] = entityId + report.updated++ + } else { + await repository.createWithId(entityId, finalEntity) + idMap[entry.imported.id] = entityId + report.imported++ + } + + if (setNestedData) { + try { + await setNestedData(entityId, finalEntity) + } catch (ne) { + errors.push(ne.message) + } + } + } catch (e) { + errors.push(e.message) + } + } + } + + function _prepareEntity(entity, titleField, createdBy, suffix, remapOptions) { + const cloned = { ...entity } + cloned.created_by = createdBy + + // Apply title/suffix + if (titleField) { + cloned[titleField] = suffix ? `${entity[titleField]} [${suffix}]` : entity[titleField] + } + + // Remap combo exercises + if (remapOptions.remapExercises && cloned.exercises?.length) { + cloned.exercises = cloned.exercises.map((ex) => ({ + ...ex, + exercise_id: remapOptions.remapExercises[ex.exercise_id] || ex.exercise_id, + })) + } + + // Remap session items + if ((remapOptions.remapExercises || remapOptions.remapCombos) && cloned.items?.length) { + cloned.items = cloned.items.map((item) => { + if (item.item_type === 'exercise' && remapOptions.remapExercises) { + return { ...item, item_id: remapOptions.remapExercises[item.item_id] || item.item_id } + } + if (item.item_type === 'combo' && remapOptions.remapCombos) { + return { ...item, item_id: remapOptions.remapCombos[item.item_id] || item.item_id } + } + return item + }) + } + + // Remap collection sessions + if (remapOptions.remapSessions && cloned.sessions?.length) { + cloned.sessions = cloned.sessions.map((s) => ({ + ...s, + session_id: remapOptions.remapSessions[s.session_id] || s.session_id, + })) + } + + return cloned + } + + async function _importExecutionLogs( + analysis, + isSameUser, + localUuid, + exporterUuid, + idMap, + report, + errors, + ) { + // Collect all logs to import from the three analysis buckets + const logsToImport = [ + ...(analysis.noCollision || []).map((log) => ({ log, action: 'insert' })), + ...(analysis.sameUserCollisions || []).map((e) => ({ log: e.imported, action: 'insert' })), + ...(analysis.differentUser || []).map((e) => ({ log: e.imported, action: e.action })), + ] + + for (const { log, action } of logsToImport) { + try { + const remappedSessionId = idMap.session[log.session_id] || log.session_id + const sessionExists = await sessionRepository.getById(remappedSessionId) + if (!sessionExists) { + report.skipped++ + continue + } + + const logId = action === 'new_id' ? crypto.randomUUID() : log.id + await executionLogRepository.createWithId(logId, { + ...log, + session_id: remappedSessionId, + }) + + if (log.items?.length) { + const remappedItems = log.items.map((item) => ({ + ...item, + ...(action === 'new_id' ? { id: crypto.randomUUID(), log_id: logId } : {}), + exercise_id: idMap.exercise[item.exercise_id] || item.exercise_id, + })) + await executionLogRepository.addItems(logId, remappedItems) + } + + report.imported++ + } catch (e) { + errors.push(e.message) + } + } + } + + return { + importing, + error, + loadFile, + ensureSuffix, + analyzeAll, + applyAllImport, + } +} diff --git a/src/composables/useKeyboardShortcut.js b/src/composables/useKeyboardShortcut.js new file mode 100644 index 0000000..641374e --- /dev/null +++ b/src/composables/useKeyboardShortcut.js @@ -0,0 +1,30 @@ +import { onMounted, onBeforeUnmount } from 'vue' + +/** + * Registers a keyboard shortcut listener for a single key. + * + * @param {string} key - The key to listen for (e.g. 'e', 'n', 'escape', 'enter') + * @param {Function} handler - Callback to invoke when the key is pressed + * @param {boolean|object} options - Legacy boolean (preventDefault) or options object: + * - preventDefault {boolean} - Whether to call event.preventDefault() (default: true) + * - ctrl {boolean} - Require Ctrl to be held (default: false) + * - ignoreInputs {boolean} - Skip when focus is in input/textarea/select (default: true) + */ +export function useKeyboardShortcut(key, handler, options = {}) { + if (typeof options === 'boolean') options = { preventDefault: options } + const { preventDefault = true, ctrl = false, ignoreInputs = true } = options + + function onKeydown(event) { + if (event.key.toLowerCase() !== key.toLowerCase()) return + if (ctrl && !event.ctrlKey) return + + const tag = event.target.tagName.toLowerCase() + if (ignoreInputs && (tag === 'input' || tag === 'textarea' || tag === 'select')) return + + if (preventDefault) event.preventDefault() + handler() + } + + onMounted(() => window.addEventListener('keydown', onKeydown)) + onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) +} diff --git a/src/composables/useListNavigation.js b/src/composables/useListNavigation.js new file mode 100644 index 0000000..13fa876 --- /dev/null +++ b/src/composables/useListNavigation.js @@ -0,0 +1,55 @@ +import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue' + +/** + * Provides keyboard navigation for a list of items using arrow keys. + * + * @param {number} itemCount - The number of items in the list + * @param {Function} onSelect - Callback when Enter is pressed on a focused item (receives index) + * @returns {{ focusedIndex: Ref, onKeydown: Function, focusItem: Function }} + */ +export function useListNavigation(itemCount, onSelect) { + const focusedIndex = ref(-1) + + function focusItem(index) { + focusedIndex.value = Math.max(0, Math.min(index, itemCount.value - 1)) + nextTick(() => { + const el = document.querySelector(`[data-list-index="${focusedIndex.value}"]`) + el?.focus() + }) + } + + function onKeydown(event) { + const tag = event.target.tagName.toLowerCase() + if (tag === 'input' || tag === 'textarea' || tag === 'select') return + + if (event.key === 'ArrowDown') { + event.preventDefault() + if (focusedIndex.value < 0) { + focusItem(0) + } else { + focusItem(focusedIndex.value + 1) + } + } else if (event.key === 'ArrowUp') { + event.preventDefault() + if (focusedIndex.value > 0) { + focusItem(focusedIndex.value - 1) + } + } else if (event.key === 'Enter' && focusedIndex.value >= 0) { + event.preventDefault() + onSelect(focusedIndex.value) + } else if (event.key === 'Escape' && focusedIndex.value >= 0) { + event.preventDefault() + focusedIndex.value = -1 + } + } + + onMounted(() => { + window.addEventListener('keydown', onKeydown) + }) + + onBeforeUnmount(() => { + window.removeEventListener('keydown', onKeydown) + }) + + return { focusedIndex, onKeydown, focusItem } +} diff --git a/src/composables/useOnlineStatus.js b/src/composables/useOnlineStatus.js new file mode 100644 index 0000000..c0b6eb8 --- /dev/null +++ b/src/composables/useOnlineStatus.js @@ -0,0 +1,21 @@ +import { ref, onMounted, onUnmounted } from 'vue' + +export function useOnlineStatus() { + const isOnline = ref(navigator.onLine) + + function update() { + isOnline.value = navigator.onLine + } + + onMounted(() => { + window.addEventListener('online', update) + window.addEventListener('offline', update) + }) + + onUnmounted(() => { + window.removeEventListener('online', update) + window.removeEventListener('offline', update) + }) + + return { isOnline } +} diff --git a/src/composables/useSaveFilePrompt.js b/src/composables/useSaveFilePrompt.js new file mode 100644 index 0000000..1025d2e --- /dev/null +++ b/src/composables/useSaveFilePrompt.js @@ -0,0 +1,37 @@ +import { ref } from 'vue' + +const pendingSave = ref(null) + +function splitFilename(filename) { + const idx = filename.lastIndexOf('.') + if (idx <= 0) return { stem: filename, extension: '' } + return { stem: filename.slice(0, idx), extension: filename.slice(idx) } +} + +/** + * Singleton prompt service backing the global . + * promptFilename() resolves to the chosen filename, or null if cancelled. + */ +export function useSaveFilePrompt() { + function promptFilename(defaultFilename) { + return new Promise((resolve) => { + const { stem, extension } = splitFilename(defaultFilename) + pendingSave.value = { defaultStem: stem, extension, resolve } + }) + } + + function confirmFilename(stem) { + const { defaultStem, extension, resolve } = pendingSave.value + const finalStem = stem.trim() || defaultStem + pendingSave.value = null + resolve(`${finalStem}${extension}`) + } + + function cancelFilename() { + const { resolve } = pendingSave.value + pendingSave.value = null + resolve(null) + } + + return { pendingSave, promptFilename, confirmFilename, cancelFilename } +} diff --git a/src/composables/useSessionExecution.js b/src/composables/useSessionExecution.js new file mode 100644 index 0000000..ed85a13 --- /dev/null +++ b/src/composables/useSessionExecution.js @@ -0,0 +1,451 @@ +import { ref, computed } from 'vue' +import { sessionRepository } from '../db/repositories/sessionRepository.js' +import { comboRepository } from '../db/repositories/comboRepository.js' +import { exerciseRepository } from '../db/repositories/exerciseRepository.js' +import { executionLogRepository } from '../db/repositories/executionLogRepository.js' +import { settingsRepository } from '../db/repositories/settingsRepository.js' +import { useTimer } from './useTimer.js' +import { useAudioCues } from './useAudioCues.js' + +export function useSessionExecution(sessionId) { + const sessionTitle = ref('') + const queue = ref([]) + const currentIndex = ref(0) + const logId = ref(null) + const showFinalRound = ref(false) + const sessionDone = ref(false) + const loading = ref(true) + const error = ref(null) + + const currentReps = ref(0) + const currentWeight = ref(0.0) + const currentSide = ref('left') + + // Timer state + const timerPhase = ref(null) // 'work' | 'rest' | 'amrap' | null + const isAmrapTimeUp = ref(false) + const currentAmrapComboId = ref(null) + + const { + remaining: timerRemaining, + isPaused: timerPaused, + start: startTimer, + pause: pauseTimer, + resume: resumeTimer, + stop: stopTimer, + } = useTimer() + const { playBip, playBIP } = useAudioCues() + const audioVolume = ref(0.5) + + const currentStep = computed(() => queue.value[currentIndex.value] || null) + const nextStep = computed(() => queue.value[currentIndex.value + 1] || null) + const totalSteps = computed(() => queue.value.length) + const timerActive = computed(() => timerPhase.value !== null && !isAmrapTimeUp.value) + + function loadStepState() { + const step = currentStep.value + if (!step) return + currentReps.value = step.prefillReps + currentWeight.value = step.prefillWeight + currentSide.value = step.prefillSide + _startStepTimer(step) + } + + function _emomSounds(remaining) { + if (remaining === 30 || remaining === 2 || remaining === 1) playBip(audioVolume.value) + } + + function _amrapLastMinuteSounds(remaining) { + if (remaining === 30 || remaining === 2 || remaining === 1) playBip(audioVolume.value) + } + + function _tabataCountdown(remaining, totalTime) { + const half = Math.floor(totalTime / 2) + if (remaining === half) playBip(audioVolume.value) + if (remaining === 2 || remaining === 1) playBip(audioVolume.value) + } + + function _startStepTimer(step) { + if (step.comboType === 'EMOM') { + timerPhase.value = 'work' + startTimer(60, { + onTick: _emomSounds, + onExpire: _onEmomExpire, + }) + } else if (step.comboType === 'TABATA') { + _startWorkPhase(step) + } else if (step.comboType === 'AMRAP') { + _maybeStartAmrapTimer(step) + } else { + timerPhase.value = null + } + } + + function _onEmomExpire() { + playBIP(audioVolume.value) + const step = currentStep.value + if (!step) return + timerPhase.value = null + _saveStepToLog(step).then(() => _advance()) + } + + function _startWorkPhase(step) { + timerPhase.value = 'work' + playBIP(audioVolume.value) + startTimer(step.workTime, { + onTick: (r) => _tabataCountdown(r, step.workTime), + onExpire: () => _startRestPhase(step), + }) + } + + function _startRestPhase(step) { + timerPhase.value = 'rest' + playBIP(audioVolume.value) + startTimer(step.restTime, { + onTick: (r) => _tabataCountdown(r, step.restTime), + onExpire: async () => { + timerPhase.value = null + if (step.isFinalComboStep) { + showFinalRound.value = true + } else { + await _advance() + } + }, + }) + } + + function _maybeStartAmrapTimer(step) { + if (step.comboId === currentAmrapComboId.value) return + currentAmrapComboId.value = step.comboId + isAmrapTimeUp.value = false + timerPhase.value = 'amrap' + + const durationSec = step.amrapMinutes * 60 + const halfDuration = Math.round(durationSec / 2) + let lastElapsedMin = 0 + + startTimer(durationSec, { + onTick: (r) => { + if (r <= 60) { + _amrapLastMinuteSounds(r) + } else { + const elapsed = Math.floor((durationSec - r) / 60) + const isNewMinute = elapsed > lastElapsedMin + if (isNewMinute) lastElapsedMin = elapsed + if (r === halfDuration) { + playBIP(audioVolume.value) + } else if (isNewMinute) { + playBip(audioVolume.value) + } + } + }, + onExpire: () => { + playBIP(audioVolume.value) + timerPhase.value = null + isAmrapTimeUp.value = true + }, + }) + } + + async function init() { + loading.value = true + error.value = null + try { + const volumeStr = await settingsRepository.get('audio_volume') + const parsed = parseInt(volumeStr, 10) + audioVolume.value = isNaN(parsed) ? 0.5 : parsed / 100 + + const session = await sessionRepository.getById(sessionId) + if (!session) throw new Error('Session not found') + sessionTitle.value = session.title + + const prefillMap = await executionLogRepository.getLastItemsBySession(sessionId) + + const steps = [] + for (const item of session.items) { + if (item.item_type === 'exercise') { + const ex = await exerciseRepository.getById(item.item_id) + const pf = prefillMap[item.item_id] || {} + steps.push({ + exerciseId: item.item_id, + exerciseTitle: item.title, + prefillReps: pf.reps_done ?? item.repetitions, + prefillWeight: pf.weight_used ?? item.weight, + prefillSide: pf.side || 'left', + comboId: null, + comboTitle: null, + comboType: null, + workTime: null, + restTime: null, + amrapMinutes: null, + roundNumber: null, + totalRounds: null, + asymmetric: ex?.asymmetric || false, + alternate: ex?.alternate || false, + isFinalComboStep: false, + comboExercises: null, + }) + } else if (item.item_type === 'combo') { + const combo = await comboRepository.getById(item.item_id) + if (!combo || !combo.exercises.length) continue + + const comboType = combo.type || 'NONE' + const timerCfg = combo.timer_config || {} + const workTime = timerCfg.workTime ?? 20 + const restTime = timerCfg.restTime ?? 10 + const amrapMinutes = item.repetitions || 1 + + // AMRAP: treat repetitions as minutes, unroll just 1 round in queue + // EMOM: repetitions = total duration in minutes; rounds = floor(minutes / exercises) + // TABATA/NONE: repetitions = number of full combo rounds + const totalRounds = + comboType === 'AMRAP' + ? 1 + : comboType === 'EMOM' && combo.exercises.length > 0 + ? Math.max(1, Math.floor((item.repetitions || 1) / combo.exercises.length)) + : item.repetitions || 1 + + for (let round = 1; round <= totalRounds; round++) { + for (let ei = 0; ei < combo.exercises.length; ei++) { + const ex = combo.exercises[ei] + const pf = prefillMap[ex.exercise_id] || {} + const isLast = ei === combo.exercises.length - 1 && round === totalRounds + steps.push({ + exerciseId: ex.exercise_id, + exerciseTitle: ex.title, + prefillReps: pf.reps_done ?? ex.default_reps, + prefillWeight: pf.weight_used ?? ex.default_weight, + prefillSide: pf.side || 'left', + comboId: item.item_id, + comboTitle: combo.title, + comboType, + workTime, + restTime, + amrapMinutes, + roundNumber: round, + totalRounds, + asymmetric: ex.asymmetric || false, + alternate: ex.alternate || false, + isFinalComboStep: isLast, + comboExercises: combo.exercises, + }) + } + } + } + } + queue.value = steps + + logId.value = await executionLogRepository.create({ + session_id: sessionId, + started_at: new Date().toISOString(), + finished_at: null, + }) + + loadStepState() + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function _saveStepToLog(step) { + await executionLogRepository.addItem({ + log_id: logId.value, + exercise_id: step.exerciseId, + combo_id: step.comboId || null, + round_number: step.roundNumber || null, + reps_done: currentReps.value, + weight_used: currentWeight.value, + side: step.asymmetric ? currentSide.value : null, + completed: true, + timestamp: new Date().toISOString(), + }) + step.actualReps = currentReps.value + step.actualWeight = currentWeight.value + step.actualSide = currentSide.value + } + + async function markDone() { + const step = currentStep.value + if (!step || sessionDone.value) return + + // Stop running step timer (EMOM or TABATA work phase) + if (step.comboType === 'EMOM' || (step.comboType === 'TABATA' && timerPhase.value === 'work')) { + stopTimer() + timerPhase.value = null + } + + await _saveStepToLog(step) + + if (step.comboType === 'TABATA') { + _startRestPhase(step) + } else if (step.comboType === 'EMOM') { + // EMOM always auto-advances, no final-round screen + await _advance() + } else if (step.comboType === 'AMRAP') { + if (step.isFinalComboStep) { + if (isAmrapTimeUp.value) { + await _advance() + } else { + // Auto-cycle: add one more round silently + _addAmrapRound() + } + } else { + await _advance() + } + } else { + if (step.isFinalComboStep) { + showFinalRound.value = true + } else { + await _advance() + } + } + } + + function _appendRound({ resetFinalRound = false } = {}) { + const step = currentStep.value + if (!step?.comboId || !step?.comboExercises) return + + const newRound = step.roundNumber + 1 + const exercises = step.comboExercises + const lastRoundSteps = queue.value.filter( + (s) => s.comboId === step.comboId && s.roundNumber === step.roundNumber, + ) + + const newSteps = exercises.map((ex, i) => { + const prev = lastRoundSteps.find((s) => s.exerciseId === ex.exercise_id) + return { + exerciseId: ex.exercise_id, + exerciseTitle: ex.title, + prefillReps: prev?.actualReps ?? prev?.prefillReps ?? ex.default_reps, + prefillWeight: prev?.actualWeight ?? prev?.prefillWeight ?? ex.default_weight, + prefillSide: prev?.actualSide || 'left', + comboId: step.comboId, + comboTitle: step.comboTitle, + comboType: step.comboType, + workTime: step.workTime, + restTime: step.restTime, + amrapMinutes: step.amrapMinutes, + roundNumber: newRound, + totalRounds: newRound, + asymmetric: ex.asymmetric || false, + alternate: ex.alternate || false, + isFinalComboStep: i === exercises.length - 1, + comboExercises: exercises, + } + }) + + queue.value.splice(currentIndex.value + 1, 0, ...newSteps) + if (resetFinalRound) showFinalRound.value = false + currentIndex.value++ + loadStepState() + } + + function _addAmrapRound() { + _appendRound() + } + + async function continueAfterCombo() { + showFinalRound.value = false + await _advance() + } + + async function continueAfterAmrap() { + isAmrapTimeUp.value = false + currentAmrapComboId.value = null + stopTimer() + // Skip any remaining steps belonging to this AMRAP combo + const comboId = currentStep.value?.comboId + while ( + currentIndex.value < queue.value.length && + queue.value[currentIndex.value]?.comboId === comboId + ) { + currentIndex.value++ + } + if (currentIndex.value >= queue.value.length) { + await _finish() + } else { + loadStepState() + } + } + + function addOneMoreRound() { + _appendRound({ resetFinalRound: true }) + } + + function togglePause() { + if (timerPaused.value) { + resumeTimer() + } else { + pauseTimer() + } + } + + async function _advance() { + currentIndex.value++ + if (currentIndex.value >= queue.value.length) { + stopTimer() + timerPhase.value = null + await _finish() + } else { + // If the new step is a different AMRAP combo, stop the current AMRAP timer + const nextStep = currentStep.value + if (nextStep?.comboType !== 'AMRAP' || nextStep?.comboId !== currentAmrapComboId.value) { + if (currentAmrapComboId.value) { + stopTimer() + currentAmrapComboId.value = null + timerPhase.value = null + } + } + loadStepState() + } + } + + async function _finish() { + await executionLogRepository.update(logId.value, { + finished_at: new Date().toISOString(), + status: 'completed', + }) + sessionDone.value = true + } + + async function exit() { + if (sessionDone.value) return + stopTimer() + timerPhase.value = null + await executionLogRepository.update(logId.value, { + finished_at: new Date().toISOString(), + status: 'aborted', + }) + sessionDone.value = true + } + + return { + sessionTitle, + queue, + currentIndex, + currentStep, + nextStep, + totalSteps, + showFinalRound, + sessionDone, + loading, + error, + currentReps, + currentWeight, + currentSide, + timerPhase, + timerRemaining, + timerPaused, + timerActive, + isAmrapTimeUp, + init, + markDone, + continueAfterCombo, + continueAfterAmrap, + addOneMoreRound, + togglePause, + exit, + } +} diff --git a/src/composables/useSessions.js b/src/composables/useSessions.js new file mode 100644 index 0000000..f96596e --- /dev/null +++ b/src/composables/useSessions.js @@ -0,0 +1,54 @@ +import { ref } from 'vue' +import { sessionRepository } from '../db/repositories/sessionRepository.js' + +export function useSessions() { + const sessions = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchAll() { + loading.value = true + error.value = null + try { + sessions.value = await sessionRepository.getAll() + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function create(data) { + const id = await sessionRepository.create(data) + await fetchAll() + return id + } + + async function update(id, data) { + await sessionRepository.update(id, data) + await fetchAll() + } + + async function remove(id) { + await sessionRepository.delete(id) + await fetchAll() + } + + async function search(query) { + loading.value = true + error.value = null + try { + sessions.value = await sessionRepository.search(query) + } catch (e) { + error.value = e.message + } finally { + loading.value = false + } + } + + async function getById(id) { + return sessionRepository.getById(id) + } + + return { sessions, loading, error, fetchAll, create, update, remove, search, getById } +} diff --git a/src/composables/useSettings.js b/src/composables/useSettings.js new file mode 100644 index 0000000..dd1b5a8 --- /dev/null +++ b/src/composables/useSettings.js @@ -0,0 +1,27 @@ +import { ref } from 'vue' +import { settingsRepository } from '../db/repositories/settingsRepository.js' + +export function useSettings() { + const settings = ref({}) + const loading = ref(false) + + async function fetchAll() { + loading.value = true + try { + settings.value = await settingsRepository.getAll() + } finally { + loading.value = false + } + } + + async function get(key) { + return settingsRepository.get(key) + } + + async function set(key, value) { + await settingsRepository.set(key, value) + settings.value = { ...settings.value, [key]: value } + } + + return { settings, loading, fetchAll, get, set } +} diff --git a/src/composables/useStats.js b/src/composables/useStats.js new file mode 100644 index 0000000..5f16b68 --- /dev/null +++ b/src/composables/useStats.js @@ -0,0 +1,111 @@ +import { ref, watch } from 'vue' +import { executionLogRepository } from '../db/repositories/executionLogRepository.js' + +export function useStats() { + const dateRange = ref('30d') + const frequencyGroupBy = ref('week') + const selectedExerciseId = ref('') + + const totalSessions = ref(0) + const totalExercises = ref(0) + const streak = ref(0) + const loggedExercises = ref([]) + const frequencyRaw = ref([]) + const volumeRaw = ref([]) + const progressionRaw = ref([]) + const loading = ref(false) + + function getSinceDate(range) { + if (range === 'all') return null + const d = new Date() + d.setHours(0, 0, 0, 0) + if (range === '7d') d.setDate(d.getDate() - 7) + else if (range === '30d') d.setDate(d.getDate() - 30) + else if (range === '3m') d.setMonth(d.getMonth() - 3) + return d.toISOString() + } + + function computeStreak(days) { + if (!days.length) return 0 + // Use UTC midnight to match SQLite DATE() which extracts UTC dates + const daySet = new Set(days.map((r) => r.day)) + const cur = new Date() + cur.setUTCHours(0, 0, 0, 0) + let count = 0 + while (true) { + const dateStr = cur.toISOString().slice(0, 10) + if (daySet.has(dateStr)) { + count++ + cur.setUTCDate(cur.getUTCDate() - 1) + } else { + break + } + } + return count + } + + async function loadFrequency() { + const since = getSinceDate(dateRange.value) + frequencyRaw.value = await executionLogRepository.getFrequencyData( + since, + frequencyGroupBy.value, + ) + } + + async function loadProgression() { + if (!selectedExerciseId.value) { + progressionRaw.value = [] + return + } + const since = getSinceDate(dateRange.value) + progressionRaw.value = await executionLogRepository.getProgressionData( + selectedExerciseId.value, + since, + ) + } + + async function loadAll() { + loading.value = true + try { + const since = getSinceDate(dateRange.value) + const [sessions, exercises, days, frequency, volume, logged] = await Promise.all([ + executionLogRepository.getTotalSessionsSince(since), + executionLogRepository.getTotalExercisesDone(since), + executionLogRepository.getDistinctSessionDays(), + executionLogRepository.getFrequencyData(since, frequencyGroupBy.value), + executionLogRepository.getVolumeData(since), + executionLogRepository.getLoggedExercises(), + ]) + totalSessions.value = sessions + totalExercises.value = exercises + streak.value = computeStreak(days) + frequencyRaw.value = frequency + volumeRaw.value = volume + loggedExercises.value = logged + await loadProgression() + } finally { + loading.value = false + } + } + + watch(dateRange, loadAll) + watch(frequencyGroupBy, loadFrequency) + watch(selectedExerciseId, loadProgression) + + return { + dateRange, + frequencyGroupBy, + selectedExerciseId, + totalSessions, + totalExercises, + streak, + loggedExercises, + frequencyRaw, + volumeRaw, + progressionRaw, + loading, + loadAll, + loadProgression, + getSinceDate, + } +} diff --git a/src/composables/useTimer.js b/src/composables/useTimer.js new file mode 100644 index 0000000..26f2e8e --- /dev/null +++ b/src/composables/useTimer.js @@ -0,0 +1,92 @@ +import { ref, onUnmounted } from 'vue' + +export function useTimer() { + const remaining = ref(0) + const isPaused = ref(false) + let intervalId = null + let onTickFn = null + let onExpireFn = null + let endsAt = 0 + let pausedAt = 0 + let lastRemaining = 0 + + function start(seconds, { onTick, onExpire } = {}) { + _clear() + remaining.value = seconds + lastRemaining = seconds + isPaused.value = false + onTickFn = onTick || null + onExpireFn = onExpire || null + endsAt = Date.now() + seconds * 1000 + intervalId = setInterval(_step, 250) + } + + function _step() { + if (isPaused.value) return + + const now = Date.now() + const newRemaining = Math.max(0, Math.ceil((endsAt - now) / 1000)) + + if (newRemaining < lastRemaining) { + // Fire onTick for each integer second that passed (catches skipped seconds) + for (let s = lastRemaining - 1; s >= newRemaining; s--) { + if (s > 0) onTickFn?.(s) + } + lastRemaining = newRemaining + } + + remaining.value = newRemaining + + if (newRemaining <= 0) { + _clear() + onExpireFn?.() + } + } + + function pause() { + if (isPaused.value) return + isPaused.value = true + pausedAt = Date.now() + } + + function resume() { + if (!isPaused.value) return + // Shift endsAt forward by the paused duration + endsAt += Date.now() - pausedAt + isPaused.value = false + } + + function stop() { + _clear() + remaining.value = 0 + isPaused.value = false + onTickFn = null + onExpireFn = null + endsAt = 0 + pausedAt = 0 + lastRemaining = 0 + } + + function _clear() { + if (intervalId) { + clearInterval(intervalId) + intervalId = null + } + } + + // Recompute remaining immediately when the tab becomes visible + function _onVisibilityChange() { + if (!document.hidden && !isPaused.value && intervalId) { + _step() + } + } + + document.addEventListener('visibilitychange', _onVisibilityChange) + + onUnmounted(() => { + _clear() + document.removeEventListener('visibilitychange', _onVisibilityChange) + }) + + return { remaining, isPaused, start, pause, resume, stop } +} diff --git a/src/db/database.js b/src/db/database.js new file mode 100644 index 0000000..da39c6c --- /dev/null +++ b/src/db/database.js @@ -0,0 +1,160 @@ +class Database { + constructor() { + this._worker = null + this._messageId = 0 + this._pending = new Map() + this._initPromise = null + this._broken = false + this.info = null + // Set when the DB is older than the app: { from, to }. The app must show + // the blocking migration screen and call runPendingMigrations(). + this.migrationPending = null + } + + async init() { + if (this._initPromise) return this._initPromise + this._initPromise = this._doInit() + return this._initPromise + } + + async _doInit() { + this._worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' }) + this._worker.onmessage = (e) => this._handleMessage(e) + this._worker.onerror = (e) => this._handleError(e) + + // Initialize SQLite + OPFS + // Compute the absolute base URL at runtime so the worker can locate + // sqlite3.wasm correctly regardless of the deployment subdirectory. + const wasmBaseUrl = new URL(import.meta.env.BASE_URL, window.location.href).href + this.info = await this._send('init', { + dbName: __APP_NAME__, + wasmBaseUrl, + }) + + // Detect the schema state (creates the schema on a fresh DB) + const status = await this._send('getSchemaStatus') + + if (status.status === 'incompatible') { + throw new Error('DB_NEWER_THAN_APP') + } + + if (status.status === 'migration-pending') { + // Pause: the UI must obtain a user backup, then call runPendingMigrations(). + // The DB is open (exportDatabase works) but defaults are not initialized yet. + this.migrationPending = { from: status.from, to: status.to } + return this.info + } + + // Initialize default settings + await this._initDefaults() + + return this.info + } + + async runPendingMigrations() { + const result = await this._send('runMigrations') + if (result.status === 'migration-failed') { + console.error('DB migration failed:', result) + throw new Error('DB_MIGRATION_FAILED') + } + this.migrationPending = null + await this._initDefaults() + return result + } + + async _initDefaults() { + // Check if user_uuid exists (first launch detection) + const existing = await this._send('selectOne', { + sql: "SELECT value FROM settings WHERE key = 'user_uuid'", + }) + + if (!existing) { + const uuid = crypto.randomUUID() + const defaults = [ + ['user_uuid', uuid], + ['user_name', 'User'], + ['language', 'en'], + ['theme', 'light'], + ['db_created_at', new Date().toISOString()], + ] + for (const [key, value] of defaults) { + await this._send('exec', { + sql: 'INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)', + params: [key, value], + }) + } + } + + // Always update app_version to current + await this._send('exec', { + sql: 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', + params: ['app_version', __APP_VERSION__], + }) + } + + _handleMessage(e) { + const { id, result, error } = e.data + const resolver = this._pending.get(id) + if (resolver) { + this._pending.delete(id) + if (error) resolver.reject(new Error(error)) + else resolver.resolve(result) + } + } + + _handleError(e) { + console.error('DB Worker error:', e) + this._broken = true + for (const [, { reject }] of this._pending) { + reject(new Error('Worker crashed')) + } + this._pending.clear() + window.dispatchEvent(new CustomEvent('db-connection-lost')) + } + + _send(type, args = {}) { + if (this._broken) { + return Promise.reject(new Error('DB connection lost')) + } + return new Promise((resolve, reject) => { + const id = ++this._messageId + this._pending.set(id, { resolve, reject }) + this._worker.postMessage({ id, type, ...args }) + }) + } + + async batch(statements) { + return this._send('batch', { statements }) + } + + async exec(sql, params) { + return this._send('exec', { sql, params }) + } + + async run(sql, params) { + return this._send('run', { sql, params }) + } + + async selectAll(sql, params) { + return this._send('selectAll', { sql, params }) + } + + async selectOne(sql, params) { + return this._send('selectOne', { sql, params }) + } + + async exportDatabase() { + return this._send('exportDb') + } + + async importDatabase(bytes) { + return this._send('importDb', { bytes }) + } + + async resetDatabase() { + await this._send('resetDb') + await this._initDefaults() + } +} + +export const db = new Database() diff --git a/src/db/releases.js b/src/db/releases.js new file mode 100644 index 0000000..f5f01c8 --- /dev/null +++ b/src/db/releases.js @@ -0,0 +1,83 @@ +export const RELEASES = [ + { + schemaVersion: 1, + appVersion: '1.0.0', + dbBreak: false, + migrationScript: null, + }, +] + +/** + * Minimum tables a valid TrainUs DB must contain. + */ +export const REQUIRED_TABLES = ['schema_version', 'settings'] + +/** + * Ordered migration scripts to bring a live DB from `fromVersion` up to `toVersion`. + * Returns [{ schemaVersion, script }] for releases in (fromVersion, toVersion], ascending. + * A release with `migrationScript: null` and `dbBreak: false` is a valid no-op migration + * (only the schema_version row is stamped). Every schemaVersion bump must ship exactly + * one RELEASES entry. + * Throws if a release in range has `dbBreak: true` without a migration script. + */ +export function getMigrationScripts(fromVersion, toVersion) { + const releasesInRange = RELEASES.filter( + (r) => r.schemaVersion > fromVersion && r.schemaVersion <= toVersion, + ).sort((a, b) => a.schemaVersion - b.schemaVersion) + + const blocked = releasesInRange.find((r) => r.dbBreak && !r.migrationScript) + if (blocked) { + throw new Error(`Missing migration script for breaking schema version ${blocked.schemaVersion}`) + } + + return releasesInRange.map((r) => ({ + schemaVersion: r.schemaVersion, + script: r.migrationScript, + })) +} + +/** + * Check if two schema versions are compatible. + * Returns { compatible, needsAdapt, scripts } where: + * - compatible: true if migration path exists (or same version) + * - needsAdapt: true if migration scripts need to run + * - scripts: array of SQL migration scripts to run sequentially + */ +export function checkCompatibility(importedVersion, currentVersion) { + if (importedVersion === currentVersion) { + return { compatible: true, needsAdapt: false, scripts: [] } + } + + // Determine direction: upgrading imported data to current schema + const ascending = importedVersion < currentVersion + const fromVersion = Math.min(importedVersion, currentVersion) + const toVersion = Math.max(importedVersion, currentVersion) + + // Collect releases between fromVersion (exclusive) and toVersion (inclusive) + const releasesInRange = RELEASES.filter( + (r) => r.schemaVersion > fromVersion && r.schemaVersion <= toVersion, + ).sort((a, b) => a.schemaVersion - b.schemaVersion) + + const hasBreak = releasesInRange.some((r) => r.dbBreak) + + if (!hasBreak) { + // No breaking changes in the range — compatible without adaptation + return { compatible: true, needsAdapt: false, scripts: [] } + } + + // There are breaking changes — collect migration scripts + const scriptsNeeded = ascending + ? releasesInRange.filter((r) => r.dbBreak) + : releasesInRange.filter((r) => r.dbBreak).reverse() + + const missingScript = scriptsNeeded.some((r) => !r.migrationScript) + if (missingScript) { + return { compatible: false, needsAdapt: true, scripts: [] } + } + + return { + compatible: true, + needsAdapt: true, + scripts: scriptsNeeded.map((r) => r.migrationScript), + } +} diff --git a/src/db/repositories/collectionRepository.js b/src/db/repositories/collectionRepository.js new file mode 100644 index 0000000..193745e --- /dev/null +++ b/src/db/repositories/collectionRepository.js @@ -0,0 +1,122 @@ +import { db } from '../database.js' +import { escapeLike } from '../../utils/escapeLike.js' + +export const collectionRepository = { + async create(collection) { + const id = crypto.randomUUID() + await db.run('INSERT INTO collection (id, label, created_by) VALUES (?, ?, ?)', [ + id, + collection.label, + collection.created_by, + ]) + return id + }, + + async getById(id) { + const row = await db.selectOne('SELECT * FROM collection WHERE id = ?', [id]) + if (!row) return null + row.sessions = await this.getSessions(id) + return row + }, + + async getAll() { + const collections = await db.selectAll('SELECT * FROM collection ORDER BY label') + for (const c of collections) { + const row = await db.selectOne( + 'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?', + [c.id], + ) + c.sessionCount = row ? row.sessionCount : 0 + } + return collections + }, + + async getAllWithSessions() { + const collections = await db.selectAll('SELECT * FROM collection ORDER BY label') + for (const c of collections) { + c.sessions = await this.getSessions(c.id) + const row = await db.selectOne( + 'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?', + [c.id], + ) + c.sessionCount = row ? row.sessionCount : 0 + } + return collections + }, + + async searchWithSessions(query) { + const collections = await this.search(query) + for (const c of collections) { + c.sessions = await this.getSessions(c.id) + const row = await db.selectOne( + 'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?', + [c.id], + ) + c.sessionCount = row ? row.sessionCount : 0 + } + return collections + }, + + async update(id, data) { + return db.run('UPDATE collection SET label = ? WHERE id = ?', [data.label, id]) + }, + + async delete(id) { + return db.run('DELETE FROM collection WHERE id = ?', [id]) + }, + + async createWithId(id, collection) { + await db.run('INSERT INTO collection (id, label, created_by) VALUES (?, ?, ?)', [ + id, + collection.label, + collection.created_by, + ]) + return id + }, + + async replaceAll(id, collection) { + await db.run( + `UPDATE collection SET label = ?, created_by = ? + WHERE id = ?`, + [collection.label, collection.created_by, id], + ) + }, + + async search(query) { + return db.selectAll("SELECT * FROM collection WHERE label LIKE ? ESCAPE '\\' ORDER BY label", [ + `%${escapeLike(query)}%`, + ]) + }, + + async getSessions(collectionId) { + return db.selectAll( + `SELECT cs.position, cs.session_id, s.title + FROM collection_session cs + JOIN session s ON s.id = cs.session_id + WHERE cs.collection_id = ? + ORDER BY cs.position`, + [collectionId], + ) + }, + + async getRawSessions(collectionId) { + return db.selectAll( + `SELECT cs.position, cs.session_id + FROM collection_session cs + WHERE cs.collection_id = ? + ORDER BY cs.position`, + [collectionId], + ) + }, + + async setSessions(collectionId, sessionIds) { + const statements = [ + { sql: 'DELETE FROM collection_session WHERE collection_id = ?', params: [collectionId] }, + ...sessionIds.map((sessionId, i) => ({ + sql: 'INSERT INTO collection_session (collection_id, session_id, position) VALUES (?, ?, ?)', + params: [collectionId, sessionId, i], + })), + ] + await db.batch(statements) + }, +} diff --git a/src/db/repositories/comboRepository.js b/src/db/repositories/comboRepository.js new file mode 100644 index 0000000..0702677 --- /dev/null +++ b/src/db/repositories/comboRepository.js @@ -0,0 +1,151 @@ +import { db } from '../database.js' +import { escapeLike } from '../../utils/escapeLike.js' + +function deserialize(row) { + return { + ...row, + timer_config: JSON.parse(row.timer_config || '{}'), + } +} + +export const comboRepository = { + async create(combo) { + const id = crypto.randomUUID() + await db.run( + `INSERT INTO combo (id, title, description, type, timer_config, created_by) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + id, + combo.title, + combo.description || '', + combo.type, + JSON.stringify(combo.timer_config || {}), + combo.created_by, + ], + ) + return id + }, + + async getById(id) { + const row = await db.selectOne('SELECT * FROM combo WHERE id = ?', [id]) + if (!row) return null + const combo = deserialize(row) + combo.exercises = await this.getExercises(id) + return combo + }, + + async getAll() { + const rows = await db.selectAll('SELECT * FROM combo ORDER BY title') + return rows.map(deserialize) + }, + + async getAllWithExercises() { + const combos = await this.getAll() + for (const combo of combos) { + combo.exercises = await this.getExercises(combo.id) + } + return combos + }, + + async searchWithExercises(query) { + const combos = await this.search(query) + for (const combo of combos) { + combo.exercises = await this.getExercises(combo.id) + } + return combos + }, + + async update(id, data) { + return db.run( + `UPDATE combo SET title = ?, description = ?, type = ?, timer_config = ? + WHERE id = ?`, + [data.title, data.description || '', data.type, JSON.stringify(data.timer_config || {}), id], + ) + }, + + async delete(id) { + return db.run('DELETE FROM combo WHERE id = ?', [id]) + }, + + async createWithId(id, combo) { + await db.run( + `INSERT INTO combo (id, title, description, type, timer_config, created_by) + VALUES (?, ?, ?, ?, ?, ?)`, + [ + id, + combo.title, + combo.description || '', + combo.type, + JSON.stringify(combo.timer_config || {}), + combo.created_by, + ], + ) + return id + }, + + async replaceAll(id, combo) { + await db.run( + `UPDATE combo SET title = ?, description = ?, type = ?, timer_config = ?, created_by = ? + WHERE id = ?`, + [ + combo.title, + combo.description || '', + combo.type, + JSON.stringify(combo.timer_config || {}), + combo.created_by, + id, + ], + ) + }, + + async search(query) { + const rows = await db.selectAll( + "SELECT * FROM combo WHERE title LIKE ? ESCAPE '\\' ORDER BY title", + [`%${escapeLike(query)}%`], + ) + return rows.map(deserialize) + }, + + async getExercises(comboId) { + const rows = await db.selectAll( + `SELECT ce.position, ce.exercise_id, ce.default_reps, ce.default_weight, + e.title, e.asymmetric, e.alternate + FROM combo_exercise ce + JOIN exercise e ON e.id = ce.exercise_id + WHERE ce.combo_id = ? + ORDER BY ce.position`, + [comboId], + ) + return rows.map((r) => ({ + ...r, + asymmetric: Boolean(r.asymmetric), + alternate: Boolean(r.alternate), + })) + }, + + async getRawExercises(comboId) { + return db.selectAll( + `SELECT ce.position, ce.exercise_id, ce.default_reps, ce.default_weight + FROM combo_exercise ce + WHERE ce.combo_id = ? + ORDER BY ce.position`, + [comboId], + ) + }, + + async setExercises(comboId, exercises) { + const statements = [ + { sql: 'DELETE FROM combo_exercise WHERE combo_id = ?', params: [comboId] }, + ...exercises.map((exercise, i) => { + const exerciseId = typeof exercise === 'string' ? exercise : exercise.exerciseId + const defaultReps = typeof exercise === 'object' ? exercise.defaultReps || 1 : 1 + const defaultWeight = typeof exercise === 'object' ? exercise.defaultWeight || 0.0 : 0.0 + return { + sql: 'INSERT INTO combo_exercise (combo_id, exercise_id, position, default_reps, default_weight) VALUES (?, ?, ?, ?, ?)', + params: [comboId, exerciseId, i, defaultReps, defaultWeight], + } + }), + ] + await db.batch(statements) + }, +} diff --git a/src/db/repositories/executionLogRepository.js b/src/db/repositories/executionLogRepository.js new file mode 100644 index 0000000..cac6925 --- /dev/null +++ b/src/db/repositories/executionLogRepository.js @@ -0,0 +1,304 @@ +import { db } from '../database.js' +import { escapeLike } from '../../utils/escapeLike.js' + +export const executionLogRepository = { + async create(log) { + const id = crypto.randomUUID() + await db.run( + `INSERT INTO execution_log (id, session_id, started_at, finished_at, status) + VALUES (?, ?, ?, ?, ?)`, + [id, log.session_id, log.started_at, log.finished_at || null, log.status || 'completed'], + ) + return id + }, + + async getById(id) { + const row = await db.selectOne('SELECT * FROM execution_log WHERE id = ?', [id]) + if (!row) return null + row.items = await this.getItems(id) + return row + }, + + async getAll() { + return db.selectAll('SELECT * FROM execution_log ORDER BY started_at DESC') + }, + + async getBySessionId(sessionId) { + return db.selectAll( + 'SELECT * FROM execution_log WHERE session_id = ? ORDER BY started_at DESC', + [sessionId], + ) + }, + + async update(id, data) { + if (data.status !== undefined) { + return db.run('UPDATE execution_log SET finished_at = ?, status = ? WHERE id = ?', [ + data.finished_at, + data.status, + id, + ]) + } + return db.run('UPDATE execution_log SET finished_at = ? WHERE id = ?', [data.finished_at, id]) + }, + + async delete(id) { + return db.run('DELETE FROM execution_log WHERE id = ?', [id]) + }, + + async search(query) { + return db.selectAll( + `SELECT el.* FROM execution_log el + JOIN session s ON s.id = el.session_id + WHERE s.title LIKE ? ESCAPE '\\' + ORDER BY el.started_at DESC`, + [`%${escapeLike(query)}%`], + ) + }, + + async getItems(logId) { + return db.selectAll(`SELECT * FROM execution_log_item WHERE log_id = ? ORDER BY timestamp`, [ + logId, + ]) + }, + + async addItem(item) { + const id = crypto.randomUUID() + await db.run( + `INSERT INTO execution_log_item (id, log_id, exercise_id, combo_id, round_number, reps_done, weight_used, side, completed, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + item.log_id, + item.exercise_id, + item.combo_id || null, + item.round_number || null, + item.reps_done || 0, + item.weight_used || 0, + item.side || null, + item.completed ? 1 : 0, + item.timestamp || new Date().toISOString(), + ], + ) + return id + }, + + async createWithId(id, log) { + await db.run( + `INSERT INTO execution_log (id, session_id, started_at, finished_at, status) + VALUES (?, ?, ?, ?, ?)`, + [id, log.session_id, log.started_at, log.finished_at || null, log.status || 'completed'], + ) + return id + }, + + async addItems(logId, items) { + if (!items.length) return + const statements = items.map((item) => ({ + sql: `INSERT INTO execution_log_item (id, log_id, exercise_id, combo_id, round_number, reps_done, weight_used, side, completed, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + params: [ + item.id, + logId, + item.exercise_id, + item.combo_id || null, + item.round_number || null, + item.reps_done || 0, + item.weight_used || 0, + item.side || null, + item.completed ? 1 : 0, + item.timestamp || new Date().toISOString(), + ], + })) + await db.batch(statements) + }, + + async getLastItemsBySession(sessionId) { + const log = await db.selectOne( + 'SELECT id FROM execution_log WHERE session_id = ? ORDER BY started_at DESC LIMIT 1', + [sessionId], + ) + if (!log) return {} + const items = await db.selectAll( + 'SELECT exercise_id, reps_done, weight_used, side FROM execution_log_item WHERE log_id = ? ORDER BY timestamp', + [log.id], + ) + const map = {} + for (const item of items) { + map[item.exercise_id] = { + reps_done: item.reps_done, + weight_used: item.weight_used, + side: item.side, + } + } + return map + }, + + async getTotalSessionsSince(since = null) { + if (!since) { + const row = await db.selectOne( + "SELECT COUNT(*) AS count FROM execution_log WHERE status = 'completed'", + ) + return row ? row.count : 0 + } + return this.getCountSince(since) + }, + + async getTotalExercisesDone(since = null) { + let sql = `SELECT COUNT(*) AS count FROM execution_log_item eli + JOIN execution_log el ON el.id = eli.log_id + WHERE eli.completed = 1` + const params = [] + if (since) { + sql += ' AND el.started_at >= ?' + params.push(since) + } + const row = await db.selectOne(sql, params) + return row ? row.count : 0 + }, + + async getDistinctSessionDays() { + return db.selectAll( + `SELECT DATE(started_at) AS day FROM execution_log GROUP BY DATE(started_at) ORDER BY day DESC`, + ) + }, + + async getFrequencyData(since = null, groupBy = 'week') { + const fmt = groupBy === 'month' ? '%Y-%m' : '%Y-%W' + let sql = `SELECT strftime('${fmt}', started_at) AS period, COUNT(*) AS count FROM execution_log WHERE status = 'completed'` + const params = [] + if (since) { + sql += ' AND started_at >= ?' + params.push(since) + } + sql += ' GROUP BY period ORDER BY period' + return db.selectAll(sql, params) + }, + + async getVolumeData(since = null) { + let sql = `SELECT el.id, DATE(el.started_at) AS day, el.started_at, + SUM(eli.reps_done * eli.weight_used) AS volume + FROM execution_log el + JOIN execution_log_item eli ON eli.log_id = el.id + WHERE eli.completed = 1` + const params = [] + if (since) { + sql += ' AND el.started_at >= ?' + params.push(since) + } + sql += ' GROUP BY el.id ORDER BY el.started_at' + return db.selectAll(sql, params) + }, + + async getLoggedExercises() { + return db.selectAll( + `SELECT DISTINCT e.id, e.title + FROM exercise e + JOIN execution_log_item eli ON eli.exercise_id = e.id + ORDER BY e.title`, + ) + }, + + async getProgressionData(exerciseId, since = null) { + let sql = `SELECT el.started_at, + SUM(eli.reps_done) AS total_reps, + MAX(eli.weight_used) AS max_weight + FROM execution_log el + JOIN execution_log_item eli ON eli.log_id = el.id + WHERE eli.exercise_id = ? AND eli.completed = 1` + const params = [exerciseId] + if (since) { + sql += ' AND el.started_at >= ?' + params.push(since) + } + sql += ' GROUP BY el.id ORDER BY el.started_at' + return db.selectAll(sql, params) + }, + + async getRecent(limit = 3) { + return db.selectAll( + `SELECT el.*, s.title AS session_title + FROM execution_log el + JOIN session s ON s.id = el.session_id + ORDER BY el.started_at DESC + LIMIT ?`, + [limit], + ) + }, + + async getItemsWithTitles(logId) { + return db.selectAll( + `SELECT eli.*, e.title AS exercise_title, + c.title AS combo_title, c.type AS combo_type + FROM execution_log_item eli + JOIN exercise e ON e.id = eli.exercise_id + LEFT JOIN combo c ON c.id = eli.combo_id + WHERE eli.log_id = ? + ORDER BY eli.timestamp`, + [logId], + ) + }, + + async getRecentWithItems(limit = 4) { + const logs = await db.selectAll( + `SELECT el.*, s.title AS session_title + FROM execution_log el + JOIN session s ON s.id = el.session_id + ORDER BY el.started_at DESC + LIMIT ?`, + [limit], + ) + for (const log of logs) { + log.items = await this.getItemsWithTitles(log.id) + } + return logs + }, + + async getAllWithItems() { + const logs = await db.selectAll( + `SELECT el.*, s.title AS session_title + FROM execution_log el + JOIN session s ON s.id = el.session_id + ORDER BY el.started_at DESC`, + ) + for (const log of logs) { + log.items = await this.getItemsWithTitles(log.id) + } + return logs + }, + + async getCountSince(isoDate) { + const row = await db.selectOne( + `SELECT COUNT(*) AS count FROM execution_log WHERE status = 'completed' AND started_at >= ?`, + [isoDate], + ) + return row ? row.count : 0 + }, + + async countItemsByExercise(exerciseId) { + const row = await db.selectOne( + `SELECT COUNT(*) AS count FROM execution_log_item WHERE exercise_id = ?`, + [exerciseId], + ) + return row ? row.count : 0 + }, + + async countLogsBySession(sessionId) { + const row = await db.selectOne( + `SELECT COUNT(*) AS count FROM execution_log WHERE session_id = ?`, + [sessionId], + ) + return row ? row.count : 0 + }, + + async getCountByDateRange(from, to) { + const row = await db.selectOne( + `SELECT COUNT(*) AS count FROM execution_log WHERE DATE(started_at) BETWEEN ? AND ?`, + [from, to], + ) + return row ? row.count : 0 + }, + + async deleteByDateRange(from, to) { + return db.run(`DELETE FROM execution_log WHERE DATE(started_at) BETWEEN ? AND ?`, [from, to]) + }, +} diff --git a/src/db/repositories/exerciseRepository.js b/src/db/repositories/exerciseRepository.js new file mode 100644 index 0000000..bd1112a --- /dev/null +++ b/src/db/repositories/exerciseRepository.js @@ -0,0 +1,126 @@ +import { db } from '../database.js' +import { escapeLike } from '../../utils/escapeLike.js' + +function deserialize(row) { + return { + ...row, + asymmetric: Boolean(row.asymmetric), + alternate: Boolean(row.alternate), + image_urls: JSON.parse(row.image_urls || '[]'), + video_urls: JSON.parse(row.video_urls || '[]'), + } +} + +export const exerciseRepository = { + async create(exercise) { + const id = crypto.randomUUID() + await db.run( + `INSERT INTO exercise (id, title, description, asymmetric, alternate, + image_urls, video_urls, default_reps, default_weight, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + exercise.title, + exercise.description || '', + exercise.asymmetric ? 1 : 0, + exercise.alternate ? 1 : 0, + JSON.stringify(exercise.image_urls || []), + JSON.stringify(exercise.video_urls || []), + exercise.default_reps || 0, + exercise.default_weight || 0, + exercise.created_by, + ], + ) + return id + }, + + async getById(id) { + const row = await db.selectOne('SELECT * FROM exercise WHERE id = ?', [id]) + return row ? deserialize(row) : null + }, + + async getAll() { + const rows = await db.selectAll('SELECT * FROM exercise ORDER BY title') + return rows.map(deserialize) + }, + + async update(id, data) { + return db.run( + `UPDATE exercise SET title = ?, description = ?, asymmetric = ?, + alternate = ?, image_urls = ?, video_urls = ?, + default_reps = ?, default_weight = ? + WHERE id = ?`, + [ + data.title, + data.description || '', + data.asymmetric ? 1 : 0, + data.alternate ? 1 : 0, + JSON.stringify(data.image_urls || []), + JSON.stringify(data.video_urls || []), + data.default_reps || 0, + data.default_weight || 0, + id, + ], + ) + }, + + /** + * Insert an exercise with a specific ID (for import scenarios). + */ + async createWithId(id, exercise) { + await db.run( + `INSERT INTO exercise (id, title, description, asymmetric, alternate, + image_urls, video_urls, default_reps, default_weight, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + exercise.title, + exercise.description || '', + exercise.asymmetric ? 1 : 0, + exercise.alternate ? 1 : 0, + JSON.stringify(exercise.image_urls || []), + JSON.stringify(exercise.video_urls || []), + exercise.default_reps || 0, + exercise.default_weight || 0, + exercise.created_by, + ], + ) + return id + }, + + /** + * Replace (update) all fields including created_by (for import overwrite). + */ + async replaceAll(id, data) { + return db.run( + `UPDATE exercise SET title = ?, description = ?, asymmetric = ?, + alternate = ?, image_urls = ?, video_urls = ?, + default_reps = ?, default_weight = ?, created_by = ? + WHERE id = ?`, + [ + data.title, + data.description || '', + data.asymmetric ? 1 : 0, + data.alternate ? 1 : 0, + JSON.stringify(data.image_urls || []), + JSON.stringify(data.video_urls || []), + data.default_reps || 0, + data.default_weight || 0, + data.created_by, + id, + ], + ) + }, + + async delete(id) { + return db.run('DELETE FROM exercise WHERE id = ?', [id]) + }, + + async search(query) { + const rows = await db.selectAll( + "SELECT * FROM exercise WHERE title LIKE ? ESCAPE '\\' ORDER BY title", + [`%${escapeLike(query)}%`], + ) + return rows.map(deserialize) + }, +} diff --git a/src/db/repositories/sessionRepository.js b/src/db/repositories/sessionRepository.js new file mode 100644 index 0000000..46c4163 --- /dev/null +++ b/src/db/repositories/sessionRepository.js @@ -0,0 +1,124 @@ +import { db } from '../database.js' +import { escapeLike } from '../../utils/escapeLike.js' + +export const sessionRepository = { + async create(session) { + const id = crypto.randomUUID() + await db.run( + `INSERT INTO session (id, title, description, created_by) + VALUES (?, ?, ?, ?)`, + [id, session.title, session.description || '', session.created_by], + ) + return id + }, + + async getById(id) { + const row = await db.selectOne('SELECT * FROM session WHERE id = ?', [id]) + if (!row) return null + row.items = await this.getItems(id) + return row + }, + + async getAll() { + return db.selectAll('SELECT * FROM session ORDER BY title') + }, + + async getAllWithItems() { + const sessions = await this.getAll() + for (const session of sessions) { + session.items = await this.getItems(session.id) + } + return sessions + }, + + async searchWithItems(query) { + const sessions = await this.search(query) + for (const session of sessions) { + session.items = await this.getItems(session.id) + } + return sessions + }, + + async update(id, data) { + return db.run('UPDATE session SET title = ?, description = ? WHERE id = ?', [ + data.title, + data.description || '', + id, + ]) + }, + + async delete(id) { + return db.run('DELETE FROM session WHERE id = ?', [id]) + }, + + async createWithId(id, session) { + await db.run( + `INSERT INTO session (id, title, description, created_by) + VALUES (?, ?, ?, ?)`, + [id, session.title, session.description || '', session.created_by], + ) + return id + }, + + async replaceAll(id, session) { + await db.run( + `UPDATE session SET title = ?, description = ?, created_by = ? + WHERE id = ?`, + [session.title, session.description || '', session.created_by, id], + ) + }, + + async search(query) { + return db.selectAll("SELECT * FROM session WHERE title LIKE ? ESCAPE '\\' ORDER BY title", [ + `%${escapeLike(query)}%`, + ]) + }, + + async getItems(sessionId) { + const exerciseRows = await db.selectAll( + `SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, e.title + FROM session_item si + JOIN exercise e ON e.id = si.item_id + WHERE si.session_id = ? AND si.item_type = 'exercise' + ORDER BY si.position`, + [sessionId], + ) + const comboRows = await db.selectAll( + `SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight, c.title, c.type AS combo_type + FROM session_item si + JOIN combo c ON c.id = si.item_id + WHERE si.session_id = ? AND si.item_type = 'combo' + ORDER BY si.position`, + [sessionId], + ) + return [...exerciseRows, ...comboRows].sort((a, b) => a.position - b.position) + }, + + async getRawItems(sessionId) { + return db.selectAll( + `SELECT si.position, si.item_id, si.item_type, si.repetitions, si.weight + FROM session_item si + WHERE si.session_id = ? + ORDER BY si.position`, + [sessionId], + ) + }, + + async setItems(sessionId, items) { + const statements = [ + { sql: 'DELETE FROM session_item WHERE session_id = ?', params: [sessionId] }, + ...items.map((item, i) => ({ + sql: 'INSERT INTO session_item (session_id, item_id, item_type, position, repetitions, weight) VALUES (?, ?, ?, ?, ?, ?)', + params: [ + sessionId, + item.item_id, + item.item_type, + i, + item.repetitions || 1, + item.weight || 0.0, + ], + })), + ] + await db.batch(statements) + }, +} diff --git a/src/db/repositories/settingsRepository.js b/src/db/repositories/settingsRepository.js new file mode 100644 index 0000000..1e64290 --- /dev/null +++ b/src/db/repositories/settingsRepository.js @@ -0,0 +1,21 @@ +import { db } from '../database.js' + +export const settingsRepository = { + async get(key) { + const row = await db.selectOne('SELECT value FROM settings WHERE key = ?', [key]) + return row ? row.value : null + }, + + async set(key, value) { + return db.run('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', [key, value]) + }, + + async getAll() { + const rows = await db.selectAll('SELECT key, value FROM settings') + const map = {} + for (const row of rows) { + map[row.key] = row.value + } + return map + }, +} diff --git a/src/db/repositories/suffixRepository.js b/src/db/repositories/suffixRepository.js new file mode 100644 index 0000000..3cf8bf7 --- /dev/null +++ b/src/db/repositories/suffixRepository.js @@ -0,0 +1,69 @@ +import { db } from '../database.js' + +export const suffixRepository = { + async create(entry) { + await db.run('INSERT INTO suffix (user_uuid, user_name, suffix) VALUES (?, ?, ?)', [ + entry.user_uuid, + entry.user_name, + entry.suffix, + ]) + }, + + async getByUuid(userUuid) { + return db.selectOne('SELECT * FROM suffix WHERE user_uuid = ?', [userUuid]) + }, + + async getBySuffix(suffix) { + return db.selectOne('SELECT * FROM suffix WHERE suffix = ?', [suffix]) + }, + + async getAll() { + return db.selectAll('SELECT * FROM suffix ORDER BY user_name') + }, + + async update(userUuid, data) { + return db.run('UPDATE suffix SET user_name = ?, suffix = ? WHERE user_uuid = ?', [ + data.user_name, + data.suffix, + userUuid, + ]) + }, + + async delete(userUuid) { + return db.run('DELETE FROM suffix WHERE user_uuid = ?', [userUuid]) + }, + + /** + * Renames a suffix across all entity title/label columns. + * Updates suffix table entry + all titles containing [OLD_SUFFIX] to [NEW_SUFFIX]. + */ + async renameSuffix(userUuid, newSuffix) { + const existing = await this.getByUuid(userUuid) + if (!existing) throw new Error('Suffix entry not found') + + const oldPattern = `[${existing.suffix}]` + const newPattern = `[${newSuffix}]` + + // Update suffix table + await db.run('UPDATE suffix SET suffix = ? WHERE user_uuid = ?', [newSuffix, userUuid]) + + // Update titles across entity tables + await db.run( + "UPDATE exercise SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'", + [oldPattern, newPattern, oldPattern], + ) + await db.run("UPDATE combo SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'", [ + oldPattern, + newPattern, + oldPattern, + ]) + await db.run( + "UPDATE session SET title = REPLACE(title, ?, ?) WHERE title LIKE '%' || ? || '%'", + [oldPattern, newPattern, oldPattern], + ) + await db.run( + "UPDATE collection SET label = REPLACE(label, ?, ?) WHERE label LIKE '%' || ? || '%'", + [oldPattern, newPattern, oldPattern], + ) + }, +} diff --git a/src/db/schema.js b/src/db/schema.js new file mode 100644 index 0000000..282cb7d --- /dev/null +++ b/src/db/schema.js @@ -0,0 +1,109 @@ +export const SCHEMA_VERSION = 1 + +export const SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); + +CREATE TABLE IF NOT EXISTS exercise ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + asymmetric INTEGER NOT NULL DEFAULT 0, + alternate INTEGER NOT NULL DEFAULT 0, + image_urls TEXT DEFAULT '[]', + video_urls TEXT DEFAULT '[]', + default_reps INTEGER NOT NULL DEFAULT 0, + default_weight REAL NOT NULL DEFAULT 0.0, + created_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS combo ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + type TEXT NOT NULL CHECK(type IN ('NONE', 'EMOM', 'AMRAP', 'TABATA')), + timer_config TEXT DEFAULT '{}', + created_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS combo_exercise ( + combo_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + position INTEGER NOT NULL, + default_reps INTEGER NOT NULL DEFAULT 1, + default_weight REAL NOT NULL DEFAULT 0.0, + PRIMARY KEY (combo_id, position), + FOREIGN KEY (combo_id) REFERENCES combo(id) ON DELETE CASCADE, + FOREIGN KEY (exercise_id) REFERENCES exercise(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL UNIQUE, + description TEXT DEFAULT '', + created_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS session_item ( + session_id TEXT NOT NULL, + item_id TEXT NOT NULL, + item_type TEXT NOT NULL CHECK(item_type IN ('exercise', 'combo')), + position INTEGER NOT NULL, + repetitions INTEGER NOT NULL DEFAULT 1, + weight REAL NOT NULL DEFAULT 0.0, + PRIMARY KEY (session_id, position), + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS collection ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL UNIQUE, + created_by TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS collection_session ( + collection_id TEXT NOT NULL, + session_id TEXT NOT NULL, + position INTEGER NOT NULL, + PRIMARY KEY (collection_id, position), + FOREIGN KEY (collection_id) REFERENCES collection(id) ON DELETE CASCADE, + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS execution_log ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL DEFAULT 'completed' CHECK(status IN ('completed','aborted')), + FOREIGN KEY (session_id) REFERENCES session(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS execution_log_item ( + id TEXT PRIMARY KEY, + log_id TEXT NOT NULL, + exercise_id TEXT NOT NULL, + combo_id TEXT DEFAULT NULL, + round_number INTEGER DEFAULT NULL, + reps_done INTEGER DEFAULT 0, + weight_used REAL DEFAULT 0.0, + side TEXT DEFAULT NULL, + completed INTEGER NOT NULL DEFAULT 0, + timestamp TEXT, + FOREIGN KEY (log_id) REFERENCES execution_log(id) ON DELETE CASCADE, + FOREIGN KEY (exercise_id) REFERENCES exercise(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS suffix ( + user_uuid TEXT PRIMARY KEY, + user_name TEXT NOT NULL, + suffix TEXT NOT NULL UNIQUE +); +` diff --git a/src/db/worker.js b/src/db/worker.js new file mode 100644 index 0000000..f62b298 --- /dev/null +++ b/src/db/worker.js @@ -0,0 +1,395 @@ +import sqlite3InitModule from '@sqlite.org/sqlite-wasm' +import { REQUIRED_TABLES, checkCompatibility, getMigrationScripts } from './releases.js' +import { SCHEMA_SQL, SCHEMA_VERSION } from './schema.js' + +let db = null +let sqlite3 = null + +async function initDatabase(config) { + const { dbName, wasmBaseUrl } = config + + sqlite3 = await sqlite3InitModule({ + locateFile: (file) => new URL(file, wasmBaseUrl).href, + }) + + const poolUtil = await sqlite3.installOpfsSAHPoolVfs({ + directory: `.${dbName}`, + initialCapacity: 6, + }) + db = new poolUtil.OpfsSAHPoolDb(`/${dbName}.db`) + + db.exec('PRAGMA foreign_keys = ON') + + return { vfs: 'opfs-sahpool', persistent: true } +} + +function _readDbVersion() { + const tableRows = db.exec( + "SELECT name FROM sqlite_master WHERE type='table' AND name='schema_version'", + { returnValue: 'resultRows', rowMode: 'array' }, + ) + if (tableRows.length === 0) return null + + const versionRows = db.exec('SELECT MAX(version) AS version FROM schema_version', { + returnValue: 'resultRows', + rowMode: 'object', + }) + return versionRows.length > 0 ? versionRows[0].version : null +} + +function handleGetSchemaStatus() { + const dbVersion = _readDbVersion() + + if (dbVersion === null) { + // Fresh DB: create the schema and stamp the current version + db.exec(SCHEMA_SQL) + db.exec('INSERT INTO schema_version (version) VALUES (?)', { bind: [SCHEMA_VERSION] }) + return { status: 'fresh', version: SCHEMA_VERSION } + } + + if (dbVersion === SCHEMA_VERSION) { + return { status: 'up-to-date', version: dbVersion } + } + + if (dbVersion > SCHEMA_VERSION) { + return { status: 'incompatible', dbVersion, appVersion: SCHEMA_VERSION } + } + + // Older DB — the app must obtain a user backup before calling runMigrations + return { status: 'migration-pending', from: dbVersion, to: SCHEMA_VERSION } +} + +function handleRunMigrations() { + const from = _readDbVersion() + + if (from === null || from >= SCHEMA_VERSION) { + return { status: 'up-to-date', version: from } + } + + let migrations + try { + migrations = getMigrationScripts(from, SCHEMA_VERSION) + } catch (e) { + return { status: 'migration-failed', from, to: SCHEMA_VERSION, message: e.message } + } + + for (const { schemaVersion, script } of migrations) { + try { + db.exec('BEGIN') + if (script) db.exec(script) + db.exec('INSERT INTO schema_version (version) VALUES (?)', { bind: [schemaVersion] }) + db.exec('COMMIT') + } catch (e) { + try { + db.exec('ROLLBACK') + } catch { + // no open transaction left to roll back + } + return { + status: 'migration-failed', + from, + to: SCHEMA_VERSION, + failedAt: schemaVersion, + message: e.message, + } + } + } + + return { status: 'migrated', from, to: SCHEMA_VERSION } +} + +function handleBatch(statements) { + db.exec('BEGIN IMMEDIATE') + try { + for (const { sql, params } of statements) { + db.exec(sql, { bind: params || undefined }) + } + db.exec('COMMIT') + return true + } catch (e) { + try { + db.exec('ROLLBACK') + } catch (_e) { + // Ignore — the error we care about is `e` + } + throw e + } +} + +function handleExec(sql, params) { + db.exec(sql, { bind: params || undefined }) + return true +} + +function handleRun(sql, params) { + db.exec(sql, { bind: params || undefined }) + return { changes: sqlite3.capi.sqlite3_changes(db.pointer) } +} + +function handleSelectAll(sql, params) { + return db.exec(sql, { + bind: params || undefined, + rowMode: 'object', + returnValue: 'resultRows', + }) +} + +function handleSelectOne(sql, params) { + const rows = db.exec(sql, { + bind: params || undefined, + rowMode: 'object', + returnValue: 'resultRows', + }) + return rows.length > 0 ? rows[0] : null +} + +function handleImportDb(bytes) { + const uint8 = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes) + + // 1. Try loading the file into an in-memory DB via sqlite3_deserialize + let tempDb + try { + tempDb = new sqlite3.oo1.DB(':memory:') + const n = uint8.byteLength + const pBuf = sqlite3.wasm.alloc(n) + sqlite3.wasm.heap8u().set(uint8, pBuf) + const rc = sqlite3.capi.sqlite3_deserialize( + tempDb.pointer, + 'main', + pBuf, + n, + n, + sqlite3.capi.SQLITE_DESERIALIZE_FREEONCLOSE | sqlite3.capi.SQLITE_DESERIALIZE_RESIZEABLE, + ) + if (rc !== 0) { + const msg = sqlite3.capi.sqlite3_errmsg(tempDb.pointer) + tempDb.close() + return { success: false, error: 'INVALID_FILE', message: msg || `deserialize rc=${rc}` } + } + // Verify the DB can be read + tempDb.exec('SELECT count(*) FROM sqlite_master') + } catch (e) { + if (tempDb) tempDb.close() + return { success: false, error: 'INVALID_FILE', message: e.message } + } + + // 2. Check required tables exist in the imported DB + const importedTableRows = tempDb.exec( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + { returnValue: 'resultRows', rowMode: 'array' }, + ) + const importedTableNames = importedTableRows.map((r) => r[0]) + + for (const required of REQUIRED_TABLES) { + if (!importedTableNames.includes(required)) { + tempDb.close() + return { + success: false, + error: 'INCOMPLETE_DB', + message: `Missing required table: ${required}`, + } + } + } + + // 3. Read schema versions from both DBs + const importedVersionRows = tempDb.exec( + 'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1', + { returnValue: 'resultRows', rowMode: 'array' }, + ) + const importedVersion = importedVersionRows.length > 0 ? importedVersionRows[0][0] : null + + if (importedVersion === null) { + tempDb.close() + return { + success: false, + error: 'INCOMPLETE_DB', + message: 'No schema version found in imported DB', + } + } + + const currentVersionRows = db.exec( + 'SELECT version FROM schema_version ORDER BY version DESC LIMIT 1', + { returnValue: 'resultRows', rowMode: 'array' }, + ) + const currentVersion = currentVersionRows.length > 0 ? currentVersionRows[0][0] : null + + // 4. Check compatibility + const compat = checkCompatibility(importedVersion, currentVersion) + + if (!compat.compatible) { + tempDb.close() + return { + success: false, + error: 'INCOMPATIBLE_VERSION', + message: `Schema version ${importedVersion} is incompatible with current version ${currentVersion}`, + importedVersion, + currentVersion, + } + } + + // 5. Run adapt scripts if needed + if (compat.needsAdapt) { + try { + for (const script of compat.scripts) { + tempDb.exec(script) + } + } catch (e) { + tempDb.close() + return { success: false, error: 'ADAPT_FAILED', message: e.message } + } + } + + // 6. Get current DB tables + const currentTableRows = db.exec( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + { returnValue: 'resultRows', rowMode: 'array' }, + ) + const currentTableNames = currentTableRows.map((r) => r[0]) + + // Only restore tables that exist in both databases + const tablesToRestore = importedTableNames.filter((t) => currentTableNames.includes(t)) + + // 7. Table-by-table copy inside a single transaction so a mid-restore + // failure cannot leave the live DB partially wiped. + const report = { + success: true, + tablesRestored: [], + importedVersion, + currentVersion, + adapted: compat.needsAdapt, + } + + db.exec('PRAGMA foreign_keys = OFF') + + let restoreError = null + try { + db.exec('BEGIN IMMEDIATE') + for (const table of tablesToRestore) { + db.exec(`DELETE FROM "${table}"`) + + const rows = tempDb.exec(`SELECT * FROM "${table}"`, { + returnValue: 'resultRows', + rowMode: 'object', + }) + + if (rows.length > 0) { + const cols = Object.keys(rows[0]) + const currentColRows = db.exec(`PRAGMA table_info("${table}")`, { + returnValue: 'resultRows', + rowMode: 'object', + }) + const currentCols = currentColRows.map((r) => r.name) + const colsToInsert = cols.filter((c) => currentCols.includes(c)) + + if (colsToInsert.length > 0) { + const quotedCols = colsToInsert.map((c) => `"${c}"`).join(', ') + const placeholders = colsToInsert.map(() => '?').join(', ') + const insertSql = `INSERT INTO "${table}" (${quotedCols}) VALUES (${placeholders})` + const stmt = db.prepare(insertSql) + try { + for (const row of rows) { + stmt.bind(colsToInsert.map((c) => row[c])) + stmt.stepReset() + } + } finally { + stmt.finalize() + } + } + } + + report.tablesRestored.push({ table, rows: rows.length }) + } + db.exec('COMMIT') + } catch (e) { + try { + db.exec('ROLLBACK') + } catch (_e) { + // Ignore — the error we care about is `e` + } + restoreError = e + } finally { + db.exec('PRAGMA foreign_keys = ON') + tempDb.close() + } + + if (restoreError) { + return { success: false, error: 'RESTORE_FAILED', message: restoreError.message } + } + return report +} + +function handleResetDb() { + const tables = [ + 'execution_log_item', + 'execution_log', + 'collection_session', + 'session_item', + 'combo_exercise', + 'suffix', + 'collection', + 'session', + 'combo', + 'exercise', + 'settings', + 'schema_version', + ] + + db.exec('PRAGMA foreign_keys = OFF') + try { + for (const table of tables) { + db.exec(`DROP TABLE IF EXISTS "${table}"`) + } + db.exec(SCHEMA_SQL) + db.exec('INSERT INTO schema_version (version) VALUES (?)', { bind: [SCHEMA_VERSION] }) + } finally { + db.exec('PRAGMA foreign_keys = ON') + } + return { success: true } +} + +self.onmessage = async (e) => { + const { id, type, ...args } = e.data + try { + let result + switch (type) { + case 'init': + result = await initDatabase(args) + break + case 'getSchemaStatus': + result = handleGetSchemaStatus() + break + case 'runMigrations': + result = handleRunMigrations() + break + case 'batch': + result = handleBatch(args.statements) + break + case 'exec': + result = handleExec(args.sql, args.params) + break + case 'run': + result = handleRun(args.sql, args.params) + break + case 'selectAll': + result = handleSelectAll(args.sql, args.params) + break + case 'selectOne': + result = handleSelectOne(args.sql, args.params) + break + case 'exportDb': + result = sqlite3.capi.sqlite3_js_db_export(db) + break + case 'importDb': + result = handleImportDb(args.bytes) + break + case 'resetDb': + result = handleResetDb() + break + default: + throw new Error(`Unknown message type: ${type}`) + } + self.postMessage({ id, result }) + } catch (err) { + self.postMessage({ id, error: err.message }) + } +} diff --git a/src/i18n/index.js b/src/i18n/index.js new file mode 100644 index 0000000..cacfe0a --- /dev/null +++ b/src/i18n/index.js @@ -0,0 +1,20 @@ +import i18next from 'i18next' +import I18NextVue from 'i18next-vue' +import en from './locales/en.json' +import fr from './locales/fr.json' +import da from './locales/da.json' + +i18next.init({ + lng: 'en', + fallbackLng: 'en', + resources: { + en: { translation: en }, + fr: { translation: fr }, + da: { translation: da }, + }, + interpolation: { + escapeValue: false, + }, +}) + +export { i18next, I18NextVue } diff --git a/src/i18n/locales/da.json b/src/i18n/locales/da.json new file mode 100644 index 0000000..1e6c85c --- /dev/null +++ b/src/i18n/locales/da.json @@ -0,0 +1,460 @@ +{ + "app": { + "name": "TrainUs", + "loading": "Indlæser…" + }, + "common": { + "markdownHint": "Grundlæggende Markdown understøttes", + "back": "Tilbage", + "clearSearch": "Ryd søgning" + }, + "error": { + "dbInitFailed": "Kunne ikke initialisere databasen", + "browserNotCompatible": "Din browser understøtter ikke de nødvendige lagringsfunktioner. Sørg for at bruge en nyere version af din browser og at du ikke er i privat browsing.", + "dbNewerThanApp": "Dine data blev oprettet af en nyere version af appen. Dine data er ikke blevet ændret. Genindlæs siden for at få den nyeste version af appen.", + "dbMigrationFailed": "Databaseopdateringen mislykkedes, og dine data blev ikke ændret. Genindlæs siden for at prøve igen, eller gendan den sikkerhedskopi, du lige har downloadet, hvis problemet fortsætter.", + "dbConnectionLost": "Databaseforbindelsen gik tabt på grund af en uventet fejl. Genindlæs siden for at genoprette forbindelsen.", + "reload": "Genindlæs" + }, + "migration": { + "title": "Databaseopdatering påkrævet", + "body": "Appen er blevet opdateret, og din database skal migreres fra version {{from}} til version {{to}}.", + "backupRequired": "Før opdateringen skal du downloade en sikkerhedskopi af dine data. Opdateringen kan først starte, når sikkerhedskopien er gemt.", + "downloadBackup": "Download sikkerhedskopi", + "update": "Opdater", + "updating": "Opdaterer…" + }, + "nav": { + "mainNav": "Hovednavigation", + "home": "Hjem", + "exercises": "Øvelser", + "combos": "Kombos", + "sessions": "Sessioner", + "collections": "Samlinger", + "stats": "Statistik", + "settings": "Indstillinger" + }, + "home": { + "title": "Hjem", + "empty": "Ingen træningssessioner udført endnu. Start en session for at se din aktivitet her!", + "recentSessions": "Seneste sessioner", + "sessionsLast7Days": "Sessioner (de seneste 7 dage)", + "sessionsLast30Days": "Sessioner (de seneste 30 dage)", + "history": "Historik", + "exportHtml": "Eksportér som HTML", + "exportedOn": "Eksporteret den {{date}}" + }, + "exercises": { + "title": "Øvelser", + "empty": "Ingen øvelser endnu. Opret din første øvelse for at komme i gang!", + "searchPlaceholder": "Søg i øvelser…", + "noResults": "Ingen øvelser matcher din søgning.", + "new": "Ny øvelse", + "edit": "Rediger øvelse", + "detail": "Øvelsesdetaljer", + "deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.", + "deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} journalpost(er). Dette kan ikke fortrydes.", + "deleted": "Øvelse slettet.", + "form": { + "title": "Titel", + "titlePlaceholder": "f.eks. Armstrækninger", + "description": "Beskrivelse", + "descriptionPlaceholder": "Valgfri beskrivelse…", + "asymmetric": "Asymmetrisk øvelse", + "alternate": "Skift side ved hver gentagelse", + "alternateFalse": "Gennemfør alle gentagelser på én side, skift derefter", + "imageUrls": "Billed-URL'er", + "videoUrls": "Video-URL'er", + "urlPlaceholder": "https://…", + "addUrl": "Tilføj URL", + "removeUrl": "Fjern", + "defaultReps": "Standardgentagelser", + "defaultWeight": "Standardvægt (kg)", + "save": "Gem", + "cancel": "Annuller" + }, + "reps": "gent.", + "weight": "kg", + "asymmetricLabel": "Asymmetrisk", + "alternateLabel": "Alternerende", + "delete": "Slet øvelse", + "videoOffline": "Videoer er ikke tilgængelige offline.", + "evenRepsHint": "Antal gentagelser skal være lige for asymmetriske øvelser." + }, + "combos": { + "title": "Kombos", + "empty": "Ingen kombos endnu. Opret din første kombo for at komme i gang!", + "searchPlaceholder": "Søg i kombos…", + "noResults": "Ingen kombos matcher din søgning.", + "new": "Ny kombo", + "edit": "Rediger kombo", + "detail": "Kombodetailer", + "deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.", + "deleted": "Kombo slettet.", + "exercises": "øvelser", + "timerConfig": "Timer-konfiguration", + "delete": "Slet kombo", + "form": { + "title": "Titel", + "titlePlaceholder": "f.eks. Overkrop Blast", + "description": "Beskrivelse", + "descriptionPlaceholder": "Valgfri beskrivelse…", + "type": "Type", + "exercises": "Øvelser", + "selectExercise": "Vælg en øvelse", + "noExercises": "Ingen øvelser tilgængelige — opret øvelser først", + "addExercise": "Tilføj øvelse", + "reps": "Gent.", + "weight": "Vægt (kg)", + "save": "Gem", + "cancel": "Annuller" + }, + "types": { + "none": "Ingen", + "emom": "EMOM", + "amrap": "AMRAP", + "tabata": "TABATA" + }, + "timer": { + "emomDuration": "Varighed", + "amrapDuration": "Varighed", + "tabataWorkTime": "Arbejdstid (s)", + "tabataRestTime": "Hviletid (s)", + "tabataSeconds": "sekunder", + "minutes": "minutter", + "rounds": "runder" + } + }, + "collections": { + "title": "Samlinger", + "empty": "Ingen samlinger endnu. Opret din første samling for at organisere dine sessioner!", + "searchPlaceholder": "Søg i samlinger…", + "noResults": "Ingen samlinger matcher din søgning.", + "new": "Ny samling", + "edit": "Rediger samling", + "detail": "Samlingsdetaljer", + "deleteConfirm": "Er du sikker på, at du vil slette \"{{label}}\"? Dette kan ikke fortrydes.", + "deleted": "Samling slettet.", + "sessions": "sessioner", + "delete": "Slet samling", + "form": { + "label": "Etiket", + "labelPlaceholder": "f.eks. Ugentlig træning", + "sessions": "Sessioner", + "selectSession": "Vælg en session", + "noSessions": "Ingen sessioner tilgængelige — opret sessioner først", + "addSession": "Tilføj session", + "save": "Gem", + "cancel": "Annuller" + } + }, + "sessions": { + "title": "Sessioner", + "empty": "Ingen sessioner endnu.", + "searchPlaceholder": "Søg i sessioner…", + "noResults": "Ingen sessioner matcher din søgning.", + "new": "Ny session", + "edit": "Rediger session", + "detail": "Sessionsdetaljer", + "deleteConfirm": "Er du sikker på, at du vil slette \"{{title}}\"? Dette kan ikke fortrydes.", + "deleteConfirmWithHistory": "Er du sikker på, at du vil slette \"{{title}}\"? Dette sletter også {{count}} udførelseslog(ge). Dette kan ikke fortrydes.", + "deleted": "Session slettet.", + "items": "elementer", + "delete": "Slet session", + "itemType": { + "exercise": "Øvelse", + "combo": "Kombo" + }, + "form": { + "title": "Titel", + "titlePlaceholder": "f.eks. Overkrop dag", + "description": "Beskrivelse", + "descriptionPlaceholder": "Valgfri beskrivelse…", + "items": "Elementer", + "reps": "Gent.", + "durationMin": "Varighed (min)", + "weight": "Vægt (kg)", + "selectExercise": "Vælg en øvelse", + "selectCombo": "Vælg en kombo", + "noExercises": "Ingen øvelser tilgængelige — opret øvelser først", + "noCombos": "Ingen kombos tilgængelige — opret kombos først", + "addItem": "Tilføj element", + "save": "Gem", + "cancel": "Annuller" + } + }, + "stats": { + "title": "Statistik", + "empty": "Ingen data endnu. Udfør en session for at se din statistik.", + "usingSince": "Du har brugt TrainUs siden {{date}}", + "dateRange": { + "7d": "7 dage", + "30d": "30 dage", + "3m": "3 måneder", + "all": "Altid" + }, + "summary": { + "sessions": "Sessioner", + "exercises": "Udførte øvelser", + "streak": "Dages streak" + }, + "charts": { + "frequency": { + "title": "Frekvens", + "week": "Per uge", + "month": "Per måned", + "yLabel": "Sessioner", + "noData": "Ingen sessioner i denne periode." + }, + "volume": { + "title": "Volumen", + "yLabel": "Volumen (kg·gent.)", + "noData": "Ingen volumedata i denne periode." + }, + "progression": { + "title": "Progression", + "selectExercise": "Vælg en øvelse…", + "reps": "Gent.", + "weight": "Vægt (kg)", + "noData": "Ingen data for denne øvelse i denne periode." + } + } + }, + "settings": { + "title": "Indstillinger", + "language": "Sprog", + "userName": "Brugernavn", + "userNamePlaceholder": "Indtast dit navn", + "nameRequired": "Navn må ikke være tomt", + "userId": "Bruger-id", + "theme": "Tema", + "themeLight": "Lys", + "themeDark": "Mørk", + "themeTrainus": "TrainUs", + "database": "Database", + "downloadDb": "Download database", + "downloadDbHint": "Download eller gendan den rå SQLite-databasefil.", + "restoreDb": "Gendan database", + "restoreDbWarning": "Dette vil erstatte ALLE nuværende data med indholdet af den valgte fil. Dette kan ikke fortrydes.\n\nNuværende appversion: {{version}}\n\nEr du sikker på, at du vil fortsætte?", + "restoreInProgress": "Gendanner database…", + "restoreSuccess": "Database gendannet", + "restoreError": "Gendannelse af database mislykkedes", + "restoreAdapted": "Skema tilpasset fra version {{from}} til {{to}}.", + "restoreRows": "række(r)", + "restoreDismiss": "OK", + "restoreErrorCodes": { + "INVALID_FILE": "Den valgte fil er ikke en gyldig SQLite-database eller er beskadiget.", + "INCOMPLETE_DB": "Den valgte fil er ikke en gyldig TrainUs-database (manglende påkrævede tabeller).", + "INCOMPATIBLE_VERSION": "Databaseversionen er inkompatibel, og ingen migreringsvej er tilgængelig.", + "ADAPT_FAILED": "Tilpasning af databaseskema mislykkedes.", + "RESTORE_FAILED": "Der opstod en fejl under gendannelse af data.", + "UNEXPECTED": "Der opstod en uventet fejl." + }, + "exportImport": "Eksporter / Importer", + "exportAllData": "Eksporter alle data", + "exportData": "Eksporter data", + "importData": "Importer data", + "importAsMine": "Importer som mine", + "comingSoon": "Kommer snart i en fremtidig opdatering.", + "suffixManagement": "Importsuffikser", + "suffixHint": "Administrer suffikser til at skelne importerede øvelser fra andre brugere.", + "suffixDeleteConfirm": "Slet suffikset \"{{suffix}}\" for {{name}}? Importerede øvelser slettes ikke.", + "suffixDeleteTitle": "Slet suffiks", + "backupReminderInvalid": "Skal være mellem 1 og 365", + "initializeDb": "Initialiser database", + "initializeDbHint": "Slet alle data og nulstil databasen til dens indledende tilstand. Denne handling kan ikke fortrydes.", + "initializeDbTitle": "Initialiser database", + "initializeDbConfirm": "Jeg forstår, at dette vil slette alle mine data permanent og ikke kan fortrydes", + "backup": { + "lastBackup": "Seneste sikkerhedskopi: {{when}}", + "neverBackedUp": "Aldrig sikkerhedskopieret", + "reminderEvery": "Påmind mig hver", + "reminderEveryUnit": "dage" + }, + "sound": "Lyd", + "soundVolume": "Lydstyrke", + "soundTest": "Test", + "credits": "Kreditering", + "manageJournal": "Administrer øvelseslogge", + "manageJournalHint": "Slet øvelseslogposter inden for et datointerval.", + "cleanJournalBtn": "Ryd journaldata" + }, + "cleanJournal": { + "title": "Ryd journaldata", + "hint": "Vælg et datointerval for permanent at slette alle øvelseslogposter inden for det.", + "from": "Fra", + "to": "Til", + "errorToBeforeFrom": "Slutdato skal være på eller efter startdato.", + "matchCount_one": "{{count}} session vil blive slettet.", + "matchCount_other": "{{count}} sessioner vil blive slettet.", + "matchCount": "{{count}} session(er) vil blive slettet.", + "confirmLabel": "Jeg forstår, at dette permanent vil slette de valgte øvelseslogposter og ikke kan fortrydes.", + "deleteBtn": "Slet" + }, + "credits": { + "title": "Kreditering", + "intro": "Open source-ikoner brugt i denne applikation:", + "appIcon": { + "name": "Mighty Force", + "description": "App-ikon, af Delapouite", + "author": "Delapouite" + }, + "bootstrapIcons": { + "name": "Bootstrap Icons", + "description": "UI-ikoner brugt gennem hele applikationen" + }, + "gameIcons": "game-icons.net vedligeholdes af Lorc, Delapouite & bidragydere", + "thirdParties": { + "title": "Tredjepartsbiblioteker" + }, + "testingTools": { + "title": "Testværktøjer" + } + }, + "export": { + "exercises": "Eksporter alle øvelser", + "exercise": "Eksporter denne øvelse", + "combos": "Eksporter alle kombos", + "combo": "Eksporter denne kombo", + "sessions": "Eksporter alle sessioner", + "session": "Eksporter denne session", + "collections": "Eksporter alle samlinger", + "collection": "Eksporter denne samling", + "executionLogs": "Udførelseslogge", + "settings": "Indstillinger", + "filterTitle": "Eksporter data", + "filterDescription": "Vælg hvilke datatyper der skal eksporteres." + }, + "import": { + "title": "Importer data", + "titleAsMine": "Importer som mine", + "exercises": "Importer øvelser", + "importAsMine": "Importer som mine", + "cancel": "Annuller", + "continue": "Fortsæt", + "apply": "Importer", + "done": "Færdig", + "importing": "Importerer…", + "exerciseCount": "øvelse(r)", + "totalCount": "element(er)", + "executionLogs": "udførelseslog(ge)", + "from": "Fra {{name}} den {{date}}", + "usingSuffix": "Suffiks: [{{suffix}}]", + "suffixExplanation": "Disse elementer er fra {{name}}. Vælg et kort suffiks for at skelne dem fra dine egne.", + "suffixLabel": "Suffiks", + "suffixPlaceholder": "f.eks. JD", + "suffixPreview": "Titler vil se sådan ud: \"{{title}} [{{suffix}}]\"", + "suffixRequired": "Suffiks er påkrævet.", + "suffixTaken": "Dette suffiks bruges allerede af en anden bruger.", + "collisionExplanation": "{{count}} element(er) er i konflikt med eksisterende data. Vælg hvad der skal gøres for hvert:", + "replace": "Erstat", + "skip": "Spring over", + "replaceAll": "Erstat alle", + "skipAll": "Spring alle over", + "exportedOn": "Eksporteret den {{date}}", + "reportTitle": "Import fuldført", + "reportTotalImported": "{{count}} element(er) importeret", + "reportImported": "{{count}} importeret", + "reportReplaced": "{{count}} erstattet", + "reportUpdated": "{{count}} opdateret", + "reportSkipped": "{{count}} sprunget over", + "errors": { + "invalidJson": "Filen indeholder ikke gyldigt JSON.", + "invalidFormat": "Filformatet genkendes ikke.", + "missingMetadata": "Filen mangler metadata.", + "wrongApp": "Denne fil blev ikke eksporteret fra TrainUs.", + "missingSchema": "Filen mangler skemaversionsinformation.", + "newerSchema": "Denne fil blev eksporteret fra en nyere version af TrainUs. Opdater venligst applikationen.", + "missingData": "Filen indeholder ingen data.", + "missingExporter": "Filen mangler eksportørinformation." + } + }, + "validation": { + "required": "Dette felt er påkrævet.", + "minValue": "Værdien skal være ≥ {{min}}.", + "invalidUrl": "Indtast en gyldig URL (https://…).", + "titleTaken": "En øvelse med denne titel findes allerede.", + "comboTitleTaken": "En kombo med denne titel findes allerede.", + "collectionLabelTaken": "En samling med denne etiket findes allerede." + }, + "shortcuts": { + "edit": "— tryk E", + "new": "— tryk N", + "back": "— tryk Esc", + "save": "— Ctrl+Enter" + }, + "execution": { + "start": "Start session", + "exit": "Afslut session", + "steps": "trin", + "now": "NU", + "next": "NÆSTE", + "round": "Runde", + "reps": "Gent.", + "weight": "Vægt", + "side": "Side", + "sideLeft": "VENSTRE", + "sideRight": "HØJRE", + "asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}", + "asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}", + "done": "Færdig", + "continue": "Fortsæt", + "oneMoreRound": "En runde til", + "comboContext": "Kombo: {{title}} — Runde {{round}} af {{total}}", + "comboComplete": "{{title}} — {{rounds}} runde(r) fuldført", + "complete": "Session fuldført!", + "backToSession": "Tilbage til session", + "timer": { + "pause": "Pause", + "resume": "Genoptag", + "rest": "HVILE", + "nextExercise": "Næste:", + "timeUp": "Tiden er gået", + "timeUpTitle": "⏱ Tiden er gået — {{title}}" + }, + "showDetails": "Vis øvelsesdetaljer", + "hideDetails": "Skjul øvelsesdetaljer", + "videoOffline": "Videoer er ikke tilgængelige offline.", + "leaveConfirm": "Du er midt i en session. Forlad alligevel? Sessionen vil blive markeret som afbrudt." + }, + "backup": { + "reminder": { + "tooltip": "Seneste sikkerhedskopi for {{days}} dag(e) siden — klik for at sikkerhedskopiere", + "tooltipNever": "Ingen sikkerhedskopi endnu — klik for at oprette en" + }, + "modal": { + "title": "Påmindelse om sikkerhedskopi", + "body": "Din seneste sikkerhedskopi var for {{days}} dag(e) siden. Vi anbefaler at sikkerhedskopiere dine data regelmæssigt.", + "bodyNever": "Du har aldrig sikkerhedskopieret dine data. Vi anbefaler at oprette en sikkerhedskopi.", + "dismiss": "Afvis", + "goToSettings": "Gå til indstillinger" + } + }, + "install": { + "button": "Installer app", + "dismiss": "Afvis installationsprompt" + }, + "modal": { + "close": "Luk dialog" + }, + "saveFile": { + "title": "Gem fil", + "label": "Filnavn", + "cancel": "Annuller", + "save": "Gem" + }, + "about": { + "title": "Om TrainUs", + "appVersion": "Applikationsversion", + "dbVersion": "Databaseversion", + "author": "Oprettet af", + "links": "Links", + "repository": "Repository", + "website": "Websted", + "license": "Licens", + "licenseBtn": "Se licens" + }, + "license": { + "title": "Licens" + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json new file mode 100644 index 0000000..c46517a --- /dev/null +++ b/src/i18n/locales/en.json @@ -0,0 +1,460 @@ +{ + "app": { + "name": "TrainUs", + "loading": "Loading…" + }, + "common": { + "markdownHint": "Basic Markdown supported", + "back": "Back", + "clearSearch": "Clear search" + }, + "error": { + "dbInitFailed": "Unable to initialize the database", + "browserNotCompatible": "Your browser does not support the required storage features. Please make sure you are using a recent version of your browser and that you are not in private browsing mode.", + "dbNewerThanApp": "Your data was created by a newer version of the app. Your data has not been modified. Please reload the page to get the latest version of the app.", + "dbMigrationFailed": "The database update failed and your data was left unchanged. Reload the page to try again, or restore the backup you just downloaded if the problem persists.", + "dbConnectionLost": "The database connection was lost due to an unexpected error. Reload the page to reconnect.", + "reload": "Reload" + }, + "migration": { + "title": "Database update required", + "body": "The app has been updated and your database needs to be migrated from version {{from}} to version {{to}}.", + "backupRequired": "Before updating, please download a backup of your data. The update can only start once the backup has been saved.", + "downloadBackup": "Download backup", + "update": "Update", + "updating": "Updating…" + }, + "nav": { + "mainNav": "Main navigation", + "home": "Home", + "exercises": "Exercises", + "combos": "Combos", + "sessions": "Sessions", + "collections": "Collections", + "stats": "Statistics", + "settings": "Settings" + }, + "home": { + "title": "Home", + "empty": "No training sessions executed yet. Start a session to see your activity here!", + "recentSessions": "Recent Sessions", + "sessionsLast7Days": "Sessions (last 7 days)", + "sessionsLast30Days": "Sessions (last 30 days)", + "history": "History", + "exportHtml": "Export as HTML", + "exportedOn": "Exported on {{date}}" + }, + "exercises": { + "title": "Exercises", + "empty": "No exercises yet. Create your first exercise to get started!", + "searchPlaceholder": "Search exercises…", + "noResults": "No exercises match your search.", + "new": "New Exercise", + "edit": "Edit Exercise", + "detail": "Exercise Details", + "deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.", + "deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal entry/entries. This cannot be undone.", + "deleted": "Exercise deleted.", + "form": { + "title": "Title", + "titlePlaceholder": "e.g. Push-ups", + "description": "Description", + "descriptionPlaceholder": "Optional description…", + "asymmetric": "Asymmetric exercise", + "alternate": "Alternate sides each repetition", + "alternateFalse": "Complete all reps on one side, then switch", + "imageUrls": "Image URLs", + "videoUrls": "Video URLs", + "urlPlaceholder": "https://…", + "addUrl": "Add URL", + "removeUrl": "Remove", + "defaultReps": "Default repetitions", + "defaultWeight": "Default weight (kg)", + "save": "Save", + "cancel": "Cancel" + }, + "reps": "reps", + "weight": "kg", + "asymmetricLabel": "Asymmetric", + "alternateLabel": "Alternating", + "delete": "Delete exercise", + "videoOffline": "Videos are not available offline.", + "evenRepsHint": "Repetitions must be even for asymmetric exercises." + }, + "combos": { + "title": "Combos", + "empty": "No combos yet. Create your first combo to get started!", + "searchPlaceholder": "Search combos…", + "noResults": "No combos match your search.", + "new": "New Combo", + "edit": "Edit Combo", + "detail": "Combo Details", + "deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.", + "deleted": "Combo deleted.", + "exercises": "exercises", + "timerConfig": "Timer Configuration", + "delete": "Delete combo", + "form": { + "title": "Title", + "titlePlaceholder": "e.g. Upper Body Blast", + "description": "Description", + "descriptionPlaceholder": "Optional description…", + "type": "Type", + "exercises": "Exercises", + "selectExercise": "Select an exercise", + "noExercises": "No exercises available — create exercises first", + "addExercise": "Add Exercise", + "reps": "Reps", + "weight": "Weight (kg)", + "save": "Save", + "cancel": "Cancel" + }, + "types": { + "none": "None", + "emom": "EMOM", + "amrap": "AMRAP", + "tabata": "TABATA" + }, + "timer": { + "emomDuration": "Duration", + "amrapDuration": "Duration", + "tabataWorkTime": "Work time (s)", + "tabataRestTime": "Rest time (s)", + "tabataSeconds": "seconds", + "minutes": "minutes", + "rounds": "rounds" + } + }, + "collections": { + "title": "Collections", + "empty": "No collections yet. Create your first collection to organize your sessions!", + "searchPlaceholder": "Search collections…", + "noResults": "No collections match your search.", + "new": "New Collection", + "edit": "Edit Collection", + "detail": "Collection Details", + "deleteConfirm": "Are you sure you want to delete \"{{label}}\"? This cannot be undone.", + "deleted": "Collection deleted.", + "sessions": "sessions", + "delete": "Delete collection", + "form": { + "label": "Label", + "labelPlaceholder": "e.g. Weekly Training", + "sessions": "Sessions", + "selectSession": "Select a session", + "noSessions": "No sessions available — create sessions first", + "addSession": "Add Session", + "save": "Save", + "cancel": "Cancel" + } + }, + "sessions": { + "title": "Sessions", + "empty": "No sessions yet.", + "searchPlaceholder": "Search sessions…", + "noResults": "No sessions match your search.", + "new": "New Session", + "edit": "Edit Session", + "detail": "Session Details", + "deleteConfirm": "Are you sure you want to delete \"{{title}}\"? This cannot be undone.", + "deleteConfirmWithHistory": "Are you sure you want to delete \"{{title}}\"? This will also delete {{count}} journal log(s). This cannot be undone.", + "deleted": "Session deleted.", + "items": "items", + "delete": "Delete session", + "itemType": { + "exercise": "Exercise", + "combo": "Combo" + }, + "form": { + "title": "Title", + "titlePlaceholder": "e.g. Upper Body Day", + "description": "Description", + "descriptionPlaceholder": "Optional description…", + "items": "Items", + "reps": "Reps", + "durationMin": "Duration (min)", + "weight": "Weight (kg)", + "selectExercise": "Select an exercise", + "selectCombo": "Select a combo", + "noExercises": "No exercises available — create exercises first", + "noCombos": "No combos available — create combos first", + "addItem": "Add Item", + "save": "Save", + "cancel": "Cancel" + } + }, + "stats": { + "title": "Statistics", + "empty": "No data yet. Execute a session to see your statistics.", + "usingSince": "You are using TrainUs since {{date}}", + "dateRange": { + "7d": "7 days", + "30d": "30 days", + "3m": "3 months", + "all": "All time" + }, + "summary": { + "sessions": "Sessions", + "exercises": "Exercises done", + "streak": "Day streak" + }, + "charts": { + "frequency": { + "title": "Frequency", + "week": "Per week", + "month": "Per month", + "yLabel": "Sessions", + "noData": "No sessions in this period." + }, + "volume": { + "title": "Volume", + "yLabel": "Volume (kg·reps)", + "noData": "No volume data in this period." + }, + "progression": { + "title": "Progression", + "selectExercise": "Select an exercise…", + "reps": "Reps", + "weight": "Weight (kg)", + "noData": "No data for this exercise in this period." + } + } + }, + "settings": { + "title": "Settings", + "language": "Language", + "userName": "User Name", + "userNamePlaceholder": "Enter your name", + "nameRequired": "Name cannot be empty", + "userId": "User ID", + "theme": "Theme", + "themeLight": "Light", + "themeDark": "Dark", + "themeTrainus": "TrainUs", + "database": "Database", + "downloadDb": "Download Database", + "downloadDbHint": "Download or restore the raw SQLite database file.", + "restoreDb": "Restore Database", + "restoreDbWarning": "This will replace ALL current data with the contents of the selected file. This cannot be undone.\n\nCurrent app version: {{version}}\n\nAre you sure you want to continue?", + "restoreInProgress": "Restoring database…", + "restoreSuccess": "Database restored successfully", + "restoreError": "Database restore failed", + "restoreAdapted": "Schema adapted from version {{from}} to {{to}}.", + "restoreRows": "row(s)", + "restoreDismiss": "OK", + "restoreErrorCodes": { + "INVALID_FILE": "The selected file is not a valid SQLite database or is corrupted.", + "INCOMPLETE_DB": "The selected file is not a valid TrainUs database (missing required tables).", + "INCOMPATIBLE_VERSION": "The database version is incompatible and no migration path is available.", + "ADAPT_FAILED": "Failed to adapt the database schema to the current version.", + "RESTORE_FAILED": "An error occurred while restoring the data.", + "UNEXPECTED": "An unexpected error occurred." + }, + "exportImport": "Export / Import", + "exportAllData": "Export All Data", + "exportData": "Export Data", + "importData": "Import Data", + "importAsMine": "Import as Mine", + "comingSoon": "Coming soon in a future update.", + "suffixManagement": "Import Suffixes", + "suffixHint": "Manage suffixes used to distinguish imported exercises from other users.", + "suffixDeleteConfirm": "Delete suffix \"{{suffix}}\" for {{name}}? This will not remove imported exercises.", + "suffixDeleteTitle": "Delete Suffix", + "backupReminderInvalid": "Must be between 1 and 365", + "initializeDb": "Initialize Database", + "initializeDbHint": "Delete all data and reset the database to its initial state. This action cannot be undone.", + "initializeDbTitle": "Initialize Database", + "initializeDbConfirm": "I understand that this will permanently delete all my data and cannot be reversed", + "backup": { + "lastBackup": "Last backup: {{when}}", + "neverBackedUp": "Never backed up", + "reminderEvery": "Remind me every", + "reminderEveryUnit": "days" + }, + "sound": "Sound", + "soundVolume": "Volume", + "soundTest": "Test", + "credits": "Credits", + "manageJournal": "Manage Exercise Logs", + "manageJournalHint": "Delete exercise log entries within a date range.", + "cleanJournalBtn": "Clean Journal Data" + }, + "cleanJournal": { + "title": "Clean Journal Data", + "hint": "Select a date range to permanently delete all exercise log entries within it.", + "from": "From", + "to": "To", + "errorToBeforeFrom": "End date must be on or after the start date.", + "matchCount_one": "{{count}} session will be deleted.", + "matchCount_other": "{{count}} sessions will be deleted.", + "matchCount": "{{count}} session(s) will be deleted.", + "confirmLabel": "I understand this will permanently delete the selected exercise log entries and cannot be undone.", + "deleteBtn": "Delete" + }, + "credits": { + "title": "Credits", + "intro": "Open-source icons used in this application:", + "appIcon": { + "name": "Mighty Force", + "description": "App icon, by Delapouite", + "author": "Delapouite" + }, + "bootstrapIcons": { + "name": "Bootstrap Icons", + "description": "UI icons used throughout the application" + }, + "gameIcons": "game-icons.net is maintained by Lorc, Delapouite & contributors", + "thirdParties": { + "title": "Third-party libraries" + }, + "testingTools": { + "title": "Testing Tools" + } + }, + "export": { + "exercises": "Export all exercises", + "exercise": "Export this exercise", + "combos": "Export all combos", + "combo": "Export this combo", + "sessions": "Export all sessions", + "session": "Export this session", + "collections": "Export all collections", + "collection": "Export this collection", + "executionLogs": "Execution Logs", + "settings": "Settings", + "filterTitle": "Export Data", + "filterDescription": "Select which data types to export." + }, + "import": { + "title": "Import Data", + "titleAsMine": "Import as Mine", + "exercises": "Import exercises", + "importAsMine": "Import as mine", + "cancel": "Cancel", + "continue": "Continue", + "apply": "Import", + "done": "Done", + "importing": "Importing…", + "exerciseCount": "exercise(s)", + "totalCount": "item(s)", + "executionLogs": "execution log(s)", + "from": "From {{name}} on {{date}}", + "usingSuffix": "Suffix: [{{suffix}}]", + "suffixExplanation": "These items are from {{name}}. Choose a short suffix to distinguish them from your own.", + "suffixLabel": "Suffix", + "suffixPlaceholder": "e.g. JD", + "suffixPreview": "Titles will look like: \"{{title}} [{{suffix}}]\"", + "suffixRequired": "Suffix is required.", + "suffixTaken": "This suffix is already used by another user.", + "collisionExplanation": "{{count}} item(s) conflict with existing data. Choose what to do for each:", + "replace": "Replace", + "skip": "Skip", + "replaceAll": "Replace All", + "skipAll": "Skip All", + "exportedOn": "Exported on {{date}}", + "reportTitle": "Import Complete", + "reportTotalImported": "{{count}} item(s) imported", + "reportImported": "{{count}} imported", + "reportReplaced": "{{count}} replaced", + "reportUpdated": "{{count}} updated", + "reportSkipped": "{{count}} skipped", + "errors": { + "invalidJson": "The file does not contain valid JSON.", + "invalidFormat": "The file format is not recognized.", + "missingMetadata": "The file is missing metadata.", + "wrongApp": "This file was not exported from TrainUs.", + "missingSchema": "The file is missing schema version information.", + "newerSchema": "This file was exported from a newer version of TrainUs. Please update the application.", + "missingData": "The file does not contain any data.", + "missingExporter": "The file is missing exporter information." + } + }, + "validation": { + "required": "This field is required.", + "minValue": "Value must be ≥ {{min}}.", + "invalidUrl": "Please enter a valid URL (https://…).", + "titleTaken": "An exercise with this title already exists.", + "comboTitleTaken": "A combo with this title already exists.", + "collectionLabelTaken": "A collection with this label already exists." + }, + "shortcuts": { + "edit": "— press E", + "new": "— press N", + "back": "— press Esc", + "save": "— Ctrl+Enter" + }, + "execution": { + "start": "Start session", + "exit": "Exit session", + "steps": "steps", + "now": "NOW", + "next": "NEXT", + "round": "Round", + "reps": "Reps", + "weight": "Weight", + "side": "Side", + "sideLeft": "LEFT", + "sideRight": "RIGHT", + "asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}", + "asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}", + "done": "Done", + "continue": "Continue", + "oneMoreRound": "One More Round", + "comboContext": "Combo: {{title}} — Round {{round}} of {{total}}", + "comboComplete": "{{title}} — {{rounds}} round(s) complete", + "complete": "Session complete!", + "backToSession": "Back to session", + "timer": { + "pause": "Pause", + "resume": "Resume", + "rest": "REST", + "nextExercise": "Next:", + "timeUp": "Time's up", + "timeUpTitle": "⏱ Time's up — {{title}}" + }, + "showDetails": "Show exercise details", + "hideDetails": "Hide exercise details", + "videoOffline": "Videos are not available offline.", + "leaveConfirm": "You are in the middle of a session. Leave anyway? The session will be marked as aborted." + }, + "backup": { + "reminder": { + "tooltip": "Last backup {{days}} day(s) ago — click to back up", + "tooltipNever": "No backup yet — click to create one" + }, + "modal": { + "title": "Backup reminder", + "body": "Your last backup was {{days}} day(s) ago. We recommend backing up your data regularly.", + "bodyNever": "You have never backed up your data. We recommend creating a backup.", + "dismiss": "Dismiss", + "goToSettings": "Go to Settings" + } + }, + "install": { + "button": "Install app", + "dismiss": "Dismiss install prompt" + }, + "modal": { + "close": "Close dialog" + }, + "saveFile": { + "title": "Save file", + "label": "File name", + "cancel": "Cancel", + "save": "Save" + }, + "about": { + "title": "About TrainUs", + "appVersion": "Application Version", + "dbVersion": "Database Version", + "author": "Created by", + "links": "Links", + "repository": "Repository", + "website": "Website", + "license": "License", + "licenseBtn": "View License" + }, + "license": { + "title": "License" + } +} diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json new file mode 100644 index 0000000..7252c89 --- /dev/null +++ b/src/i18n/locales/fr.json @@ -0,0 +1,460 @@ +{ + "app": { + "name": "TrainUs", + "loading": "Chargement…" + }, + "common": { + "markdownHint": "Markdown basique supporté", + "back": "Retour", + "clearSearch": "Effacer la recherche" + }, + "error": { + "dbInitFailed": "Impossible d'initialiser la base de données", + "browserNotCompatible": "Votre navigateur ne prend pas en charge les fonctionnalités de stockage requises. Veuillez vous assurer d'utiliser une version récente de votre navigateur et de ne pas être en navigation privée.", + "dbNewerThanApp": "Vos données ont été créées par une version plus récente de l'application. Vos données n'ont pas été modifiées. Veuillez recharger la page pour obtenir la dernière version de l'application.", + "dbMigrationFailed": "La mise à jour de la base de données a échoué et vos données n'ont pas été modifiées. Rechargez la page pour réessayer, ou restaurez la sauvegarde que vous venez de télécharger si le problème persiste.", + "dbConnectionLost": "La connexion à la base de données a été perdue suite à une erreur inattendue. Rechargez la page pour vous reconnecter.", + "reload": "Recharger" + }, + "migration": { + "title": "Mise à jour de la base de données requise", + "body": "L'application a été mise à jour et votre base de données doit être migrée de la version {{from}} vers la version {{to}}.", + "backupRequired": "Avant de mettre à jour, veuillez télécharger une sauvegarde de vos données. La mise à jour ne pourra démarrer qu'une fois la sauvegarde enregistrée.", + "downloadBackup": "Télécharger la sauvegarde", + "update": "Mettre à jour", + "updating": "Mise à jour…" + }, + "nav": { + "mainNav": "Navigation principale", + "home": "Accueil", + "exercises": "Exercices", + "combos": "Combos", + "sessions": "Séances", + "collections": "Collections", + "stats": "Statistiques", + "settings": "Réglages" + }, + "home": { + "title": "Accueil", + "empty": "Aucune séance exécutée pour le moment. Lancez une séance pour voir votre activité ici !", + "recentSessions": "Séances récentes", + "sessionsLast7Days": "Séances (7 derniers jours)", + "sessionsLast30Days": "Séances (30 derniers jours)", + "history": "Historique", + "exportHtml": "Exporter en HTML", + "exportedOn": "Exporté le {{date}}" + }, + "exercises": { + "title": "Exercices", + "empty": "Aucun exercice pour le moment. Créez votre premier exercice pour commencer !", + "searchPlaceholder": "Rechercher des exercices…", + "noResults": "Aucun exercice ne correspond à votre recherche.", + "new": "Nouvel exercice", + "edit": "Modifier l'exercice", + "detail": "Détails de l'exercice", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.", + "deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} entrée(s) du journal. Cette action est irréversible.", + "deleted": "Exercice supprimé.", + "form": { + "title": "Titre", + "titlePlaceholder": "ex. Pompes", + "description": "Description", + "descriptionPlaceholder": "Description facultative…", + "asymmetric": "Exercice asymétrique", + "alternate": "Alterner les côtés à chaque répétition", + "alternateFalse": "Compléter toutes les répétitions d'un côté, puis changer", + "imageUrls": "URLs des images", + "videoUrls": "URLs des vidéos", + "urlPlaceholder": "https://…", + "addUrl": "Ajouter une URL", + "removeUrl": "Supprimer", + "defaultReps": "Répétitions par défaut", + "defaultWeight": "Poids par défaut (kg)", + "save": "Enregistrer", + "cancel": "Annuler" + }, + "reps": "reps", + "weight": "kg", + "asymmetricLabel": "Asymétrique", + "alternateLabel": "Alternance", + "delete": "Supprimer l'exercice", + "videoOffline": "Les vidéos ne sont pas disponibles hors ligne.", + "evenRepsHint": "Le nombre de répétitions doit être pair pour les exercices asymétriques." + }, + "combos": { + "title": "Combos", + "empty": "Aucun combo pour le moment. Créez votre premier combo pour commencer !", + "searchPlaceholder": "Rechercher des combos…", + "noResults": "Aucun combo ne correspond à votre recherche.", + "new": "Nouveau Combo", + "edit": "Modifier le Combo", + "detail": "Détails du Combo", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.", + "deleted": "Combo supprimé.", + "exercises": "exercices", + "timerConfig": "Configuration du minuteur", + "delete": "Supprimer le combo", + "form": { + "title": "Titre", + "titlePlaceholder": "ex. Upper Body Blast", + "description": "Description", + "descriptionPlaceholder": "Description facultative…", + "type": "Type", + "exercises": "Exercices", + "selectExercise": "Sélectionner un exercice", + "noExercises": "Aucun exercice disponible — créez d'abord des exercices", + "addExercise": "Ajouter Exercice", + "reps": "Reps", + "weight": "Poids (kg)", + "save": "Enregistrer", + "cancel": "Annuler" + }, + "types": { + "none": "Aucun", + "emom": "EMOM", + "amrap": "AMRAP", + "tabata": "TABATA" + }, + "timer": { + "emomDuration": "Durée", + "amrapDuration": "Durée", + "tabataWorkTime": "Temps de travail (s)", + "tabataRestTime": "Temps de repos (s)", + "tabataSeconds": "secondes", + "minutes": "minutes", + "rounds": "tours" + } + }, + "collections": { + "title": "Collections", + "empty": "Aucune collection pour le moment. Créez votre première collection pour organiser vos séances !", + "searchPlaceholder": "Rechercher des collections…", + "noResults": "Aucune collection ne correspond à votre recherche.", + "new": "Nouvelle Collection", + "edit": "Modifier la Collection", + "detail": "Détails de la Collection", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{label}} » ? Cette action est irréversible.", + "deleted": "Collection supprimée.", + "sessions": "séances", + "delete": "Supprimer la collection", + "form": { + "label": "Libellé", + "labelPlaceholder": "ex. Entraînement Hebdomadaire", + "sessions": "Séances", + "selectSession": "Sélectionner une séance", + "noSessions": "Aucune séance disponible — créez d'abord des séances", + "addSession": "Ajouter Séance", + "save": "Enregistrer", + "cancel": "Annuler" + } + }, + "sessions": { + "title": "Séances", + "empty": "Aucune séance pour le moment.", + "searchPlaceholder": "Rechercher des séances…", + "noResults": "Aucune séance ne correspond à votre recherche.", + "new": "Nouvelle séance", + "edit": "Modifier la séance", + "detail": "Détails de la séance", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cette action est irréversible.", + "deleteConfirmWithHistory": "Êtes-vous sûr de vouloir supprimer « {{title}} » ? Cela supprimera également {{count}} journal(aux) d'exécution. Cette action est irréversible.", + "deleted": "Séance supprimée.", + "items": "éléments", + "delete": "Supprimer la séance", + "itemType": { + "exercise": "Exercice", + "combo": "Combo" + }, + "form": { + "title": "Titre", + "titlePlaceholder": "ex. Upper Body Day", + "description": "Description", + "descriptionPlaceholder": "Description facultative…", + "items": "Éléments", + "reps": "Reps", + "durationMin": "Durée (min)", + "weight": "Poids (kg)", + "selectExercise": "Sélectionner un exercice", + "selectCombo": "Sélectionner un combo", + "noExercises": "Aucun exercice disponible — créez d'abord des exercices", + "noCombos": "Aucun combo disponible — créez d'abord des combos", + "addItem": "Ajouter élément", + "save": "Enregistrer", + "cancel": "Annuler" + } + }, + "stats": { + "title": "Statistiques", + "empty": "Pas encore de données. Exécutez une séance pour voir vos statistiques.", + "usingSince": "Vous utilisez TrainUs depuis le {{date}}", + "dateRange": { + "7d": "7 jours", + "30d": "30 jours", + "3m": "3 mois", + "all": "Tout" + }, + "summary": { + "sessions": "Séances", + "exercises": "Exercices faits", + "streak": "Jours consécutifs" + }, + "charts": { + "frequency": { + "title": "Fréquence", + "week": "Par semaine", + "month": "Par mois", + "yLabel": "Séances", + "noData": "Aucune séance dans cette période." + }, + "volume": { + "title": "Volume", + "yLabel": "Volume (kg·rép.)", + "noData": "Aucune donnée de volume dans cette période." + }, + "progression": { + "title": "Progression", + "selectExercise": "Sélectionner un exercice…", + "reps": "Rép.", + "weight": "Poids (kg)", + "noData": "Aucune donnée pour cet exercice dans cette période." + } + } + }, + "settings": { + "title": "Réglages", + "language": "Langue", + "userName": "Nom d'utilisateur", + "userNamePlaceholder": "Entrez votre nom", + "nameRequired": "Le nom ne peut pas être vide", + "userId": "Identifiant", + "theme": "Thème", + "themeLight": "Clair", + "themeDark": "Sombre", + "themeTrainus": "TrainUs", + "database": "Base de données", + "downloadDb": "Télécharger la base de données", + "downloadDbHint": "Télécharger ou restaurer le fichier SQLite brut.", + "restoreDb": "Restaurer la base de données", + "restoreDbWarning": "Ceci va remplacer TOUTES les données actuelles par le contenu du fichier sélectionné. Cette action est irréversible.\n\nVersion actuelle de l'application : {{version}}\n\nÊtes-vous sûr de vouloir continuer ?", + "restoreInProgress": "Restauration en cours…", + "restoreSuccess": "Base de données restaurée avec succès", + "restoreError": "Échec de la restauration", + "restoreAdapted": "Schéma adapté de la version {{from}} à la version {{to}}.", + "restoreRows": "ligne(s)", + "restoreDismiss": "OK", + "restoreErrorCodes": { + "INVALID_FILE": "Le fichier sélectionné n'est pas une base de données SQLite valide ou est corrompu.", + "INCOMPLETE_DB": "Le fichier sélectionné n'est pas une base de données TrainUs valide (tables requises manquantes).", + "INCOMPATIBLE_VERSION": "La version de la base de données est incompatible et aucun chemin de migration n'est disponible.", + "ADAPT_FAILED": "Échec de l'adaptation du schéma de la base de données.", + "RESTORE_FAILED": "Une erreur est survenue lors de la restauration des données.", + "UNEXPECTED": "Une erreur inattendue est survenue." + }, + "exportImport": "Exporter / Importer", + "exportAllData": "Exporter toutes les données", + "exportData": "Exporter les données", + "importData": "Importer les données", + "importAsMine": "Importer comme les miens", + "comingSoon": "Disponible dans une prochaine mise à jour.", + "suffixManagement": "Suffixes d'importation", + "suffixHint": "Gérez les suffixes utilisés pour distinguer les exercices importés d'autres utilisateurs.", + "suffixDeleteConfirm": "Supprimer le suffixe « {{suffix}} » pour {{name}} ? Les exercices importés ne seront pas supprimés.", + "suffixDeleteTitle": "Supprimer le suffixe", + "backupReminderInvalid": "Doit être entre 1 et 365", + "initializeDb": "Initialiser la base de données", + "initializeDbHint": "Supprimer toutes les données et réinitialiser la base de données à son état initial. Cette action est irréversible.", + "initializeDbTitle": "Initialiser la base de données", + "initializeDbConfirm": "Je comprends que cela supprimera définitivement toutes mes données et ne peut pas être annulé", + "backup": { + "lastBackup": "Dernière sauvegarde : {{when}}", + "neverBackedUp": "Jamais sauvegardé", + "reminderEvery": "Me rappeler tous les", + "reminderEveryUnit": "jours" + }, + "sound": "Son", + "soundVolume": "Volume", + "soundTest": "Tester", + "credits": "Crédits", + "manageJournal": "Gérer les journaux d'exercices", + "manageJournalHint": "Supprimez les entrées du journal d'exercices sur une plage de dates.", + "cleanJournalBtn": "Nettoyer le journal" + }, + "cleanJournal": { + "title": "Nettoyer le journal", + "hint": "Sélectionnez une plage de dates pour supprimer définitivement toutes les entrées du journal d'exercices.", + "from": "Du", + "to": "Au", + "errorToBeforeFrom": "La date de fin doit être égale ou postérieure à la date de début.", + "matchCount_one": "{{count}} séance sera supprimée.", + "matchCount_other": "{{count}} séances seront supprimées.", + "matchCount": "{{count}} séance(s) seront supprimée(s).", + "confirmLabel": "Je comprends que cette action supprimera définitivement les entrées du journal sélectionnées et ne peut pas être annulée.", + "deleteBtn": "Supprimer" + }, + "credits": { + "title": "Crédits", + "intro": "Icônes open source utilisées dans cette application:", + "appIcon": { + "name": "Mighty Force", + "description": "Icône de l'application, par Delapouite", + "author": "Delapouite" + }, + "bootstrapIcons": { + "name": "Bootstrap Icons", + "description": "Icônes d'interface utilisées dans toute l'application" + }, + "gameIcons": "game-icons.net est maintenu par Lorc, Delapouite & contributeurs", + "thirdParties": { + "title": "Bibliothèques tierces" + }, + "testingTools": { + "title": "Outils de test" + } + }, + "export": { + "exercises": "Exporter tous les exercices", + "exercise": "Exporter cet exercice", + "combos": "Exporter tous les combos", + "combo": "Exporter ce combo", + "sessions": "Exporter toutes les séances", + "session": "Exporter cette séance", + "collections": "Exporter toutes les collections", + "collection": "Exporter cette collection", + "executionLogs": "Journaux d'exécution", + "settings": "Paramètres", + "filterTitle": "Exporter les données", + "filterDescription": "Sélectionnez les types de données à exporter." + }, + "import": { + "title": "Importer des données", + "titleAsMine": "Importer comme les miens", + "exercises": "Importer des exercices", + "importAsMine": "Importer comme les miens", + "cancel": "Annuler", + "continue": "Continuer", + "apply": "Importer", + "done": "Terminé", + "importing": "Importation en cours…", + "exerciseCount": "exercice(s)", + "totalCount": "élément(s)", + "executionLogs": "journal(aux) d'exécution", + "from": "De {{name}} le {{date}}", + "usingSuffix": "Suffixe : [{{suffix}}]", + "suffixExplanation": "Ces éléments proviennent de {{name}}. Choisissez un court suffixe pour les distinguer des vôtres.", + "suffixLabel": "Suffixe", + "suffixPlaceholder": "ex. JD", + "suffixPreview": "Les titres ressembleront à : « {{title}} [{{suffix}}] »", + "suffixRequired": "Le suffixe est requis.", + "suffixTaken": "Ce suffixe est déjà utilisé par un autre utilisateur.", + "collisionExplanation": "{{count}} élément(s) sont en conflit avec des données existantes. Choisissez quoi faire pour chacun :", + "replace": "Remplacer", + "skip": "Ignorer", + "replaceAll": "Tout remplacer", + "skipAll": "Tout ignorer", + "exportedOn": "Exporté le {{date}}", + "reportTitle": "Importation terminée", + "reportTotalImported": "{{count}} élément(s) importé(s)", + "reportImported": "{{count}} importé(s)", + "reportReplaced": "{{count}} remplacé(s)", + "reportUpdated": "{{count}} mis à jour", + "reportSkipped": "{{count}} ignoré(s)", + "errors": { + "invalidJson": "Le fichier ne contient pas de JSON valide.", + "invalidFormat": "Le format du fichier n'est pas reconnu.", + "missingMetadata": "Le fichier ne contient pas de métadonnées.", + "wrongApp": "Ce fichier n'a pas été exporté depuis TrainUs.", + "missingSchema": "Le fichier ne contient pas d'information de version du schéma.", + "newerSchema": "Ce fichier a été exporté depuis une version plus récente de TrainUs. Veuillez mettre à jour l'application.", + "missingData": "Le fichier ne contient aucune donnée.", + "missingExporter": "Le fichier ne contient pas d'informations sur l'exportateur." + } + }, + "validation": { + "required": "Ce champ est requis.", + "minValue": "La valeur doit être ≥ {{min}}.", + "invalidUrl": "Veuillez entrer une URL valide (https://…).", + "titleTaken": "Un exercice avec ce titre existe déjà.", + "comboTitleTaken": "Un combo avec ce titre existe déjà.", + "collectionLabelTaken": "Une collection avec ce libellé existe déjà." + }, + "shortcuts": { + "edit": "— appuyez sur E", + "new": "— appuyez sur N", + "back": "— appuyez sur Échap", + "save": "— Ctrl+Entrée" + }, + "execution": { + "start": "Démarrer la séance", + "exit": "Quitter la séance", + "steps": "étapes", + "now": "MAINTENANT", + "next": "SUIVANT", + "round": "Tour", + "reps": "Reps", + "weight": "Poids", + "side": "Côté", + "sideLeft": "GAUCHE", + "sideRight": "DROIT", + "asymAlternate": "(1x{{left}} ↔ 1x{{right}}) × {{count}}", + "asymSequential": "{{count}}x {{left}} → {{count}}x {{right}}", + "done": "Terminé", + "continue": "Continuer", + "oneMoreRound": "Un tour de plus", + "comboContext": "Combo : {{title}} — Tour {{round}} sur {{total}}", + "comboComplete": "{{title}} — {{rounds}} tour(s) terminé(s)", + "complete": "Séance terminée !", + "backToSession": "Retour à la séance", + "timer": { + "pause": "Pause", + "resume": "Reprendre", + "rest": "REPOS", + "nextExercise": "Suivant :", + "timeUp": "Temps écoulé", + "timeUpTitle": "⏱ Temps écoulé — {{title}}" + }, + "showDetails": "Afficher les détails de l'exercice", + "hideDetails": "Masquer les détails de l'exercice", + "videoOffline": "Les vidéos ne sont pas disponibles hors ligne.", + "leaveConfirm": "Vous êtes en pleine séance. Partir quand même ? La séance sera marquée comme interrompue." + }, + "backup": { + "reminder": { + "tooltip": "Dernière sauvegarde il y a {{days}} jour(s) — cliquez pour sauvegarder", + "tooltipNever": "Pas encore de sauvegarde — cliquez pour en créer une" + }, + "modal": { + "title": "Rappel de sauvegarde", + "body": "Votre dernière sauvegarde remonte à {{days}} jour(s). Nous vous recommandons de sauvegarder régulièrement.", + "bodyNever": "Vous n'avez jamais effectué de sauvegarde. Nous vous recommandons d'en créer une.", + "dismiss": "Ignorer", + "goToSettings": "Aller aux paramètres" + } + }, + "install": { + "button": "Installer l'application", + "dismiss": "Ignorer l'invitation à installer" + }, + "modal": { + "close": "Fermer la boîte de dialogue" + }, + "saveFile": { + "title": "Enregistrer le fichier", + "label": "Nom du fichier", + "cancel": "Annuler", + "save": "Enregistrer" + }, + "about": { + "title": "À propos de TrainUs", + "appVersion": "Version de l'application", + "dbVersion": "Version de la base de données", + "author": "Créé par", + "links": "Liens", + "repository": "Dépôt", + "website": "Site web", + "license": "Licence", + "licenseBtn": "Voir la licence" + }, + "license": { + "title": "Licence" + } +} diff --git a/src/main.js b/src/main.js new file mode 100644 index 0000000..49d6ccf --- /dev/null +++ b/src/main.js @@ -0,0 +1,65 @@ +import { createApp } from 'vue' +import { i18next, I18NextVue } from './i18n' +import router from './router' +import { db } from './db/database.js' +import { settingsRepository } from './db/repositories/settingsRepository.js' +import { exerciseRepository } from './db/repositories/exerciseRepository.js' +import { comboRepository } from './db/repositories/comboRepository.js' +import { sessionRepository } from './db/repositories/sessionRepository.js' +import { collectionRepository } from './db/repositories/collectionRepository.js' +import { executionLogRepository } from './db/repositories/executionLogRepository.js' +import { suffixRepository } from './db/repositories/suffixRepository.js' +import 'bootstrap-icons/font/bootstrap-icons.css' +import './styles/variables.css' +import './styles/layout.css' +import App from './App.vue' + +const app = createApp(App) +app.use(I18NextVue, { i18next }) +app.use(router) + +// Initialize database (before mount so App.vue can subscribe to the promise) +const dbReady = db + .init() + .then(async (info) => { + console.log('Database initialized:', info) + + // Apply persisted settings before first render + const lang = await settingsRepository.get('language') + if (lang) { + i18next.changeLanguage(lang) + } + const theme = await settingsRepository.get('theme') + if (theme) { + document.documentElement.setAttribute('data-theme', theme) + } + + if (db.migrationPending) { + // App stays paused: App.vue shows the blocking migration screen + document.documentElement.setAttribute('data-db-migration', 'pending') + return { migrationPending: db.migrationPending } + } + + document.documentElement.setAttribute('data-db-ready', 'true') + return {} + }) + .catch((err) => { + console.error('Database initialization failed:', err) + document.documentElement.setAttribute('data-db-error', err.message) + throw err + }) + +// Expose for testing +window.__db = db +window.__dbReady = dbReady +window.__repos = { + settings: settingsRepository, + exercises: exerciseRepository, + combos: comboRepository, + sessions: sessionRepository, + collections: collectionRepository, + executionLogs: executionLogRepository, + suffixes: suffixRepository, +} + +app.mount('#app') diff --git a/src/router/index.js b/src/router/index.js new file mode 100644 index 0000000..6763bd3 --- /dev/null +++ b/src/router/index.js @@ -0,0 +1,119 @@ +import { createRouter, createWebHashHistory } from 'vue-router' + +const routes = [ + { + path: '/', + redirect: '/home', + }, + { + path: '/home', + name: 'home', + component: () => import('../views/HomeView.vue'), + }, + { + path: '/exercises', + name: 'exercises', + component: () => import('../views/ExerciseListView.vue'), + }, + { + path: '/exercises/new', + name: 'exercise-new', + component: () => import('../views/ExerciseFormView.vue'), + }, + { + path: '/exercises/:id', + name: 'exercise-detail', + component: () => import('../views/ExerciseDetailView.vue'), + }, + { + path: '/exercises/:id/edit', + name: 'exercise-edit', + component: () => import('../views/ExerciseFormView.vue'), + }, + { + path: '/combos', + name: 'combos', + component: () => import('../views/ComboListView.vue'), + }, + { + path: '/combos/new', + name: 'combo-new', + component: () => import('../views/ComboFormView.vue'), + }, + { + path: '/combos/:id', + name: 'combo-detail', + component: () => import('../views/ComboDetailView.vue'), + }, + { + path: '/combos/:id/edit', + name: 'combo-edit', + component: () => import('../views/ComboFormView.vue'), + }, + { + path: '/sessions', + name: 'sessions', + component: () => import('../views/SessionListView.vue'), + }, + { + path: '/sessions/new', + name: 'session-new', + component: () => import('../views/SessionFormView.vue'), + }, + { + path: '/sessions/:id', + name: 'session-detail', + component: () => import('../views/SessionDetailView.vue'), + }, + { + path: '/sessions/:id/edit', + name: 'session-edit', + component: () => import('../views/SessionFormView.vue'), + }, + { + path: '/sessions/:id/execute', + name: 'session-execute', + component: () => import('../views/SessionExecuteView.vue'), + }, + { + path: '/collections', + name: 'collections', + component: () => import('../views/CollectionsView.vue'), + }, + { + path: '/collections/new', + name: 'collection-new', + component: () => import('../views/CollectionFormView.vue'), + }, + { + path: '/collections/:id', + name: 'collection-detail', + component: () => import('../views/CollectionDetailView.vue'), + }, + { + path: '/collections/:id/edit', + name: 'collection-edit', + component: () => import('../views/CollectionFormView.vue'), + }, + { + path: '/stats', + name: 'stats', + component: () => import('../views/StatsView.vue'), + }, + { + path: '/settings', + name: 'settings', + component: () => import('../views/SettingsView.vue'), + }, + { + path: '/:pathMatch(.*)*', + redirect: '/home', + }, +] + +const router = createRouter({ + history: createWebHashHistory(), + routes, +}) + +export default router diff --git a/src/styles/layout.css b/src/styles/layout.css new file mode 100644 index 0000000..b661850 --- /dev/null +++ b/src/styles/layout.css @@ -0,0 +1,276 @@ +/* Reset & base */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + height: 100%; + overflow: hidden; +} + +body { + margin: 0; + padding: 0; + font: var(--font-stack); + color: var(--front-color1); + background-color: var(--bg-color1); +} + +h1 { + margin: 0; + padding: 0; + font-size: var(--h1-size); +} + +h2, +h3 { + margin: 0; + padding: 0; +} + +a { + color: var(--btn-primary-bg); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* App layout */ +#app { + height: 100vh; + height: 100dvh; + display: flex; + overflow: hidden; +} + +/* CONTENT */ +.content { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.content-body { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +/* ACTIONBAR */ +.actionbar { + display: flex; + background-color: var(--actionbar-bg-color); + color: var(--actionbar-font-color); + padding: 0 8px; + align-items: center; + height: var(--actionbar-height); + flex-shrink: 0; + z-index: 10; +} + +.actionbar h1 { + flex: 1; + min-width: 0; + font-size: 20px; + padding-left: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +#actionbar-actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 4px; +} + +.actionbar button { + width: 40px; + min-width: 40px; + height: 36px; + padding: 0; + margin: 0; + background-color: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.3); + color: var(--actionbar-font-color); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; +} + +.actionbar button:hover { + background-color: rgba(255, 255, 255, 0.28); + border-color: rgba(255, 255, 255, 0.5); +} + +/* NAVBAR */ +.navbar { + margin: 0; + padding: 0; + width: var(--navbar-width); + flex-shrink: 0; + background-color: var(--navbar-bg-color); + overflow-y: auto; + z-index: 20; + display: flex; + flex-direction: column; +} + +.navbar-brand { + text-align: center; + padding: 12px 8px; + font-size: var(--h1-size); + font-weight: bold; + color: var(--navbar-front-color); +} + +.navbar a { + display: flex; + color: var(--navbar-front-color); + padding: 10px 12px; + text-decoration: none; + align-items: center; + gap: 8px; + font-size: 15px; +} + +.navbar a i { + font-size: 20px; + width: 24px; + text-align: center; +} + +.navbar a.router-link-active { + background-color: var(--bg-color1); + color: var(--front-color1); +} + +.navbar a:hover:not(.router-link-active) { + background-color: var(--navbar-hover-bg-color); + color: var(--navbar-hover-front-color); +} + +.navbar a:focus-visible { + outline: 2px solid var(--btn-primary-bg); + outline-offset: -2px; + background-color: var(--navbar-hover-bg-color); + color: var(--navbar-hover-front-color); +} + +.navbar-spacer { + flex: 1; +} + +/* RESPONSIVE — Tablet / Mobile */ +@media screen and (max-width: 700px) { + #app { + flex-direction: column; + } + + .navbar { + order: 2; + width: 100%; + height: auto; + flex-direction: row; + justify-content: space-around; + padding: 4px 0; + } + + .navbar-brand { + display: none; + } + + .navbar-spacer { + display: none; + } + + .navbar a { + flex-direction: column; + padding: 6px 8px; + font-size: 11px; + gap: 2px; + } + + .navbar a i { + font-size: 22px; + } + + .content { + order: 1; + } +} + +@media screen and (max-width: 600px) { + .navbar a span { + display: none; + } +} + +/* Utility classes */ +.text-muted { + color: var(--front-color2); +} + +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + transition: opacity 0.15s; +} + +.btn:hover { + opacity: 0.85; +} + +.btn-primary { + background-color: var(--btn-primary-bg); + color: var(--btn-primary-color); +} + +.btn-danger { + background-color: var(--btn-danger-bg); + color: var(--btn-danger-color); +} + +.card { + background-color: var(--bg-color2); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} + +input, +select, +textarea { + background-color: var(--input-bg); + color: var(--front-color1); + border: 1px solid var(--input-border); + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + width: 100%; +} + +input:focus, +select:focus, +textarea:focus { + outline: 2px solid var(--btn-primary-bg); + outline-offset: -1px; +} diff --git a/src/styles/variables.css b/src/styles/variables.css new file mode 100644 index 0000000..5aa7f26 --- /dev/null +++ b/src/styles/variables.css @@ -0,0 +1,76 @@ +:root { + /* FONTS */ + --font-stack: 100%, Helvetica, sans-serif; + --h1-size: 32px; + + /* COLORS — Light theme (default) */ + --bg-color1: #ffffff; + --bg-color2: #f5f5f5; + --front-color1: #1a1a1a; + --front-color2: #555555; + --navbar-bg-color: #e0e0e0; + --navbar-front-color: #1a1a1a; + --navbar-hover-bg-color: #b0b0b0; + --navbar-hover-front-color: #ffffff; + --actionbar-bg-color: #1a1a1a; + --actionbar-font-color: #ffffff; + --border-color: #ddd; + --input-bg: #ffffff; + --input-border: #ccc; + --btn-primary-bg: #2563eb; + --btn-primary-color: #ffffff; + --btn-danger-bg: #dc2626; + --btn-danger-color: #ffffff; + --success-color: #16a34a; + --warning-color: #d97706; + + /* SIZES */ + --navbar-width: 190px; + --actionbar-height: 50px; +} + +/* TrainUs theme — logo colours (#1E7FC0 blue, #12A892 teal) */ +[data-theme='trainus'] { + --bg-color1: #f0f8ff; + --bg-color2: #dceefb; + --front-color1: #0d1b2a; + --front-color2: #2c4a63; + --navbar-bg-color: #1e7fc0; + --navbar-front-color: #ffffff; + --navbar-hover-bg-color: #12a892; + --navbar-hover-front-color: #ffffff; + --actionbar-bg-color: #12a892; + --actionbar-font-color: #ffffff; + --border-color: #a8d5f0; + --input-bg: #ffffff; + --input-border: #1e7fc0; + --btn-primary-bg: #1e7fc0; + --btn-primary-color: #ffffff; + --btn-danger-bg: #dc2626; + --btn-danger-color: #ffffff; + --success-color: #12a892; + --warning-color: #d97706; +} + +/* Dark theme */ +[data-theme='dark'] { + --bg-color1: #1a1a1a; + --bg-color2: #2a2a2a; + --front-color1: #e0e0e0; + --front-color2: #aaaaaa; + --navbar-bg-color: #111111; + --navbar-front-color: #e0e0e0; + --navbar-hover-bg-color: #333333; + --navbar-hover-front-color: #ffffff; + --actionbar-bg-color: #111111; + --actionbar-font-color: #e0e0e0; + --border-color: #444; + --input-bg: #2a2a2a; + --input-border: #555; + --btn-primary-bg: #3b82f6; + --btn-primary-color: #ffffff; + --btn-danger-bg: #ef4444; + --btn-danger-color: #ffffff; + --success-color: #22c55e; + --warning-color: #f59e0b; +} diff --git a/src/utils/backupFilename.js b/src/utils/backupFilename.js new file mode 100644 index 0000000..7fe9df3 --- /dev/null +++ b/src/utils/backupFilename.js @@ -0,0 +1,10 @@ +export function formatBackupFilename(version, date = new Date()) { + const pad = (n) => String(n).padStart(2, '0') + const y = date.getFullYear() + const mo = pad(date.getMonth() + 1) + const d = pad(date.getDate()) + const h = pad(date.getHours()) + const mi = pad(date.getMinutes()) + const s = pad(date.getSeconds()) + return `trainus-${version}-backup-${y}${mo}${d}-${h}h${mi}m${s}s.sqlite3` +} diff --git a/src/utils/dateFormatter.js b/src/utils/dateFormatter.js new file mode 100644 index 0000000..de99a9e --- /dev/null +++ b/src/utils/dateFormatter.js @@ -0,0 +1,62 @@ +import i18next from 'i18next' + +export function formatDate(date, lang) { + if (!date) return '' + const d = date instanceof Date ? date : new Date(date) + if (isNaN(d.getTime())) return '' + + lang = lang || i18next.language || 'en' + const pad = (n) => String(n).padStart(2, '0') + const dd = pad(d.getDate()) + const mm = pad(d.getMonth() + 1) + const yyyy = d.getFullYear() + + if (lang === 'fr') { + return `${dd}/${mm}/${yyyy}` + } + return `${mm}/${dd}/${yyyy}` +} + +export function formatItemDuration(ms) { + if (!ms || ms <= 0) return null + const totalSecs = Math.round(ms / 1000) + if (totalSecs <= 0) return null + const mins = Math.floor(totalSecs / 60) + const secs = totalSecs % 60 + if (mins === 0) return `${secs}s` + if (secs === 0) return `${mins}min` + return `${mins}min${secs}s` +} + +export function formatDuration(start, end) { + if (!start || !end) return null + const s = start instanceof Date ? start : new Date(start) + const e = end instanceof Date ? end : new Date(end) + if (isNaN(s.getTime()) || isNaN(e.getTime())) return null + const mins = Math.round((e - s) / 60000) + if (mins <= 0) return null + if (mins < 60) return `${mins} min` + const h = Math.floor(mins / 60) + const m = mins % 60 + return m === 0 ? `${h}h` : `${h}h ${m}min` +} + +export function formatDateTime(date, lang) { + if (!date) return '' + const d = date instanceof Date ? date : new Date(date) + if (isNaN(d.getTime())) return '' + + lang = lang || i18next.language || 'en' + const pad = (n) => String(n).padStart(2, '0') + const dd = pad(d.getDate()) + const mm = pad(d.getMonth() + 1) + const yyyy = d.getFullYear() + const HH = pad(d.getHours()) + const min = pad(d.getMinutes()) + const ss = pad(d.getSeconds()) + + if (lang === 'fr') { + return `${dd}/${mm}/${yyyy}, ${HH}:${min}:${ss}` + } + return d.toLocaleString() +} diff --git a/src/utils/escapeLike.js b/src/utils/escapeLike.js new file mode 100644 index 0000000..ae45488 --- /dev/null +++ b/src/utils/escapeLike.js @@ -0,0 +1,7 @@ +/** + * Escape backslash, % and _ so they are treated as literals in a LIKE pattern. + * Use with LIKE ? ESCAPE '\' in SQL. + */ +export function escapeLike(s) { + return s.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') +} diff --git a/src/utils/jsonEnvelope.js b/src/utils/jsonEnvelope.js new file mode 100644 index 0000000..ca441b8 --- /dev/null +++ b/src/utils/jsonEnvelope.js @@ -0,0 +1,113 @@ +import { SCHEMA_VERSION } from '../db/schema.js' +import { useSaveFilePrompt } from '../composables/useSaveFilePrompt.js' + +/** + * Builds a JSON export envelope wrapping entity data. + * @param {object} data — e.g. { exercises: [...] } + * @param {{ name: string, uuid: string }} exportedBy — current user info + * @returns {object} The complete envelope + */ +export function buildEnvelope(data, exportedBy) { + return { + metadata: { + appName: __APP_NAME__, + appVersion: __APP_VERSION__, + schemaVersion: SCHEMA_VERSION, + exportDate: new Date().toISOString(), + exportedBy: { + name: exportedBy.name, + uuid: exportedBy.uuid, + }, + }, + data, + } +} + +/** + * Parses and validates a JSON envelope string. + * Returns { valid, envelope, error } where error is an i18n key. + */ +export function parseEnvelope(jsonString) { + let parsed + try { + parsed = JSON.parse(jsonString) + } catch { + return { valid: false, envelope: null, error: 'import.errors.invalidJson' } + } + + if (!parsed || typeof parsed !== 'object') { + return { valid: false, envelope: null, error: 'import.errors.invalidFormat' } + } + + const { metadata, data } = parsed + + if (!metadata || typeof metadata !== 'object') { + return { valid: false, envelope: null, error: 'import.errors.missingMetadata' } + } + + if (metadata.appName !== __APP_NAME__) { + return { valid: false, envelope: null, error: 'import.errors.wrongApp' } + } + + if (typeof metadata.schemaVersion !== 'number') { + return { valid: false, envelope: null, error: 'import.errors.missingSchema' } + } + + if (metadata.schemaVersion > SCHEMA_VERSION) { + return { valid: false, envelope: null, error: 'import.errors.newerSchema' } + } + + if (!data || typeof data !== 'object') { + return { valid: false, envelope: null, error: 'import.errors.missingData' } + } + + if (!metadata.exportedBy || !metadata.exportedBy.uuid || !metadata.exportedBy.name) { + return { valid: false, envelope: null, error: 'import.errors.missingExporter' } + } + + return { valid: true, envelope: parsed, error: null } +} + +/** + * Prompts for a filename (prefilled with `defaultFilename`), then triggers a + * browser file download. No-op if the user cancels. + * @param {string} defaultFilename + * @param {string} jsonString + */ +export async function downloadJson(defaultFilename, jsonString) { + const { promptFilename } = useSaveFilePrompt() + const filename = await promptFilename(defaultFilename) + if (!filename) return + + const blob = new Blob([jsonString], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) +} + +/** + * Opens a file picker and returns file contents as string. + * Returns null if user cancels. + */ +export function pickJsonFile() { + return new Promise((resolve) => { + const input = document.createElement('input') + input.type = 'file' + input.accept = '.json' + input.onchange = async (e) => { + const file = e.target.files[0] + if (!file) { + resolve(null) + return + } + const text = await file.text() + resolve(text) + } + // Handle cancel (focus returns without change) + input.addEventListener('cancel', () => resolve(null)) + input.click() + }) +} diff --git a/src/utils/markdown.js b/src/utils/markdown.js new file mode 100644 index 0000000..d053dc5 --- /dev/null +++ b/src/utils/markdown.js @@ -0,0 +1,7 @@ +import { marked } from 'marked' +import DOMPurify from 'dompurify' + +export function renderMarkdown(text) { + if (!text) return '' + return DOMPurify.sanitize(marked.parse(text)) +} diff --git a/src/utils/validation.js b/src/utils/validation.js new file mode 100644 index 0000000..73b7892 --- /dev/null +++ b/src/utils/validation.js @@ -0,0 +1,50 @@ +/** + * Reusable validation helpers for form fields. + * Each function returns an error i18n key string if invalid, or '' if valid. + */ + +/** + * Validates that a string is non-empty after trimming. + * @param {string} value + * @returns {string} error key or '' + */ +export function validateRequired(value) { + if (!value || !value.trim()) { + return 'validation.required' + } + return '' +} + +/** + * Validates that a number is >= min. + * @param {number|string} value + * @param {number} min + * @returns {{ key: string, params: object } | ''} error descriptor or '' + */ +export function validateMin(value, min = 0) { + const num = Number(value) + if (isNaN(num) || num < min) { + return { key: 'validation.minValue', params: { min } } + } + return '' +} + +/** + * Validates a URL format. Empty strings are considered valid (optional field). + * @param {string} value + * @returns {string} error key or '' + */ +export function validateUrl(value) { + if (!value || !value.trim()) { + return '' + } + try { + const url = new URL(value.trim()) + if (!['http:', 'https:'].includes(url.protocol)) { + return 'validation.invalidUrl' + } + return '' + } catch { + return 'validation.invalidUrl' + } +} diff --git a/src/utils/youtube.js b/src/utils/youtube.js new file mode 100644 index 0000000..244089f --- /dev/null +++ b/src/utils/youtube.js @@ -0,0 +1,14 @@ +export function getYouTubeId(url) { + try { + const u = new URL(url) + if (u.hostname === 'youtu.be') { + return u.pathname.slice(1) + } + if (u.hostname.includes('youtube.com')) { + return u.searchParams.get('v') + } + } catch { + // ignore + } + return null +} diff --git a/src/views/CollectionDetailView.vue b/src/views/CollectionDetailView.vue new file mode 100644 index 0000000..dfb941f --- /dev/null +++ b/src/views/CollectionDetailView.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/src/views/CollectionFormView.vue b/src/views/CollectionFormView.vue new file mode 100644 index 0000000..ccc8dae --- /dev/null +++ b/src/views/CollectionFormView.vue @@ -0,0 +1,428 @@ + + + + + diff --git a/src/views/CollectionsView.vue b/src/views/CollectionsView.vue new file mode 100644 index 0000000..0515694 --- /dev/null +++ b/src/views/CollectionsView.vue @@ -0,0 +1,309 @@ + + + + + diff --git a/src/views/ComboDetailView.vue b/src/views/ComboDetailView.vue new file mode 100644 index 0000000..45b2fd4 --- /dev/null +++ b/src/views/ComboDetailView.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/src/views/ComboFormView.vue b/src/views/ComboFormView.vue new file mode 100644 index 0000000..273a4be --- /dev/null +++ b/src/views/ComboFormView.vue @@ -0,0 +1,711 @@ + + + + + diff --git a/src/views/ComboListView.vue b/src/views/ComboListView.vue new file mode 100644 index 0000000..44d0ce6 --- /dev/null +++ b/src/views/ComboListView.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/src/views/CombosView.vue b/src/views/CombosView.vue new file mode 100644 index 0000000..bac8c6c --- /dev/null +++ b/src/views/CombosView.vue @@ -0,0 +1,6 @@ + diff --git a/src/views/ExerciseDetailView.vue b/src/views/ExerciseDetailView.vue new file mode 100644 index 0000000..9edcd14 --- /dev/null +++ b/src/views/ExerciseDetailView.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/src/views/ExerciseFormView.vue b/src/views/ExerciseFormView.vue new file mode 100644 index 0000000..d67d796 --- /dev/null +++ b/src/views/ExerciseFormView.vue @@ -0,0 +1,678 @@ + + + + + diff --git a/src/views/ExerciseListView.vue b/src/views/ExerciseListView.vue new file mode 100644 index 0000000..12624e7 --- /dev/null +++ b/src/views/ExerciseListView.vue @@ -0,0 +1,329 @@ + + + + + diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue new file mode 100644 index 0000000..9a2a7ef --- /dev/null +++ b/src/views/HomeView.vue @@ -0,0 +1,527 @@ + + + + + diff --git a/src/views/SessionDetailView.vue b/src/views/SessionDetailView.vue new file mode 100644 index 0000000..dd4d09c --- /dev/null +++ b/src/views/SessionDetailView.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/src/views/SessionExecuteView.vue b/src/views/SessionExecuteView.vue new file mode 100644 index 0000000..fc7f29c --- /dev/null +++ b/src/views/SessionExecuteView.vue @@ -0,0 +1,880 @@ + + + + + diff --git a/src/views/SessionFormView.vue b/src/views/SessionFormView.vue new file mode 100644 index 0000000..27f66c5 --- /dev/null +++ b/src/views/SessionFormView.vue @@ -0,0 +1,710 @@ + + + + + diff --git a/src/views/SessionListView.vue b/src/views/SessionListView.vue new file mode 100644 index 0000000..08504b8 --- /dev/null +++ b/src/views/SessionListView.vue @@ -0,0 +1,348 @@ + + + + + diff --git a/src/views/SessionsView.vue b/src/views/SessionsView.vue new file mode 100644 index 0000000..b596259 --- /dev/null +++ b/src/views/SessionsView.vue @@ -0,0 +1,6 @@ + diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue new file mode 100644 index 0000000..83be2a1 --- /dev/null +++ b/src/views/SettingsView.vue @@ -0,0 +1,1075 @@ + + + + + diff --git a/src/views/StatsView.vue b/src/views/StatsView.vue new file mode 100644 index 0000000..0e97f74 --- /dev/null +++ b/src/views/StatsView.vue @@ -0,0 +1,402 @@ + + + + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..1bed9f8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,90 @@ +# Test Suite + +End-to-end tests for TrainUs using **Playwright** (Python) and **pytest**. + +## Setup (first time) + +```bash +cd tests +python -m venv .venv +.venv/bin/pip install -r requirements.txt +.venv/bin/playwright install firefox chromium +``` + +## Running Tests + +The Vite dev server starts automatically on port 5199 via the `app_url` fixture in `conftest.py`. It is stopped automatically when all tests finish. + +### Available commands (from project root) + +| Command | Browsers | Mode | +| -------------------------------- | ------------------ | --------------------------------- | +| `npm run test` | Firefox | Sequential | +| `npm run test:chromium` | Chromium | Sequential | +| `npm run test:parallel` | Firefox + Chromium | Parallel (all CPU cores) | +| `npm run test:parallel:firefox` | Firefox | Parallel (all CPU cores) | +| `npm run test:parallel:chromium` | Chromium | Parallel (all CPU cores) | +| `npm run test:parallel:n` | Firefox + Chromium | Parallel, N workers (`WORKERS=N`) | + +### Running directly with pytest + +```bash +# All tests, Firefox only +cd tests && .venv/bin/python -m pytest --browser firefox -v + +# Single file, Firefox +cd tests && .venv/bin/python -m pytest test_step3_exercises.py --browser firefox -v + +# Parallel via shell script +./tests/run_parallel.sh + +# Parallel, Firefox only, 4 workers +PYTEST_WORKERS=4 PYTEST_BROWSERS="firefox" ./tests/run_parallel.sh + +# Single file in parallel via shell script +./tests/run_parallel.sh test_step3_exercises.py +``` + +## Browser Targets + +- **Firefox** — primary browser; all tests must pass +- **Chromium** — secondary browser; all tests must also pass + +## File Naming Convention + +``` +test_step_.py +``` + +Each file corresponds to one implementation step. Tests within a file cover both green paths (happy flow) and red paths (bad inputs, edge cases, error states). + +## Test Files + +| File | Feature | Tests | +| --------------------------------------- | ------------------------------------------- | ------- | +| `test_step0_scaffolding.py` | App shell, navigation, i18n | 5 | +| `test_step1_database.py` | DB init, CRUD, persistence | 15 | +| `test_step2_settings.py` | Language, theme, name, DB export/restore | 21 | +| `test_step2b_schema_hardening.py` | UNIQUE constraints, suffix table | 10 | +| `test_step3_exercises.py` | Exercise CRUD, search, validation | 17 | +| `test_step3b_exercise_export_import.py` | JSON export/import, collisions, suffixes | 23 | +| `test_step3c_backup.py` | Backup filename, reminder, threshold | 11 | +| `test_step4_combos.py` | Combo CRUD, exercise ordering, reps/weight | 19 | +| `test_step5_sessions.py` | Session CRUD, items, reps/weight | 20 | +| `test_step6_collections_home.py` | Collections CRUD, home page stats | 19 | +| `test_step7_import_export.py` | Full multi-entity export/import | 14 | +| `test_step8_execution.py` | Execution mode, queue, partial logs | 17 | +| `test_step9_stats.py` | Statistics charts, streak, date range | 16 | +| `test_step10_pwa.py` | PWA manifest, install prompt, accessibility | 11 | +| `test_step12_timers.py` | EMOM/AMRAP/TABATA timers, pause/resume | 20 | +| `test_step13_markdown.py` | Markdown rendering in detail views | 9 | +| `test_step14_form_shortcuts.py` | Ctrl+Enter save, Escape cancel on forms | 10 | +| `test_step15_audio_cues.py` | TABATA/AMRAP audio cue timing (fake clock) | 7 | +| **Total** | | **264** | + +## Infrastructure + +- **`conftest.py`**: Pytest fixtures. Starts the Vite dev server (`npm run dev -- --port 5199`) before the first test and stops it after all tests complete. Handles multiple concurrent workers safely using file locks. +- **`pytest.ini`**: Sets `base_url = http://localhost:5199`. +- **`run_parallel.sh`**: Shell wrapper for `pytest-xdist` parallel runs. Accepts `PYTEST_WORKERS` and `PYTEST_BROWSERS` environment variables. +- **`requirements.txt`**: `playwright`, `pytest`, `pytest-playwright`, `pytest-xdist`. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0ef0917 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,156 @@ +import urllib.request +import pytest +import subprocess +import time +import signal +import os +import fcntl + + +def _counter_add(delta: int, count_file: str, lock_file: str) -> int: + """Atomically add `delta` to the worker counter and return the new value.""" + with open(lock_file, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + try: + try: + with open(count_file) as f: + value = int(f.read().strip() or "0") + except FileNotFoundError: + value = 0 + value += delta + with open(count_file, "w") as f: + f.write(str(value)) + return value + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + + +def _counter_read(count_file: str, lock_file: str) -> int: + """Read the current worker counter value without modifying it.""" + with open(lock_file, "w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + try: + try: + with open(count_file) as f: + return int(f.read().strip() or "0") + except FileNotFoundError: + return 0 + finally: + fcntl.flock(lock, fcntl.LOCK_UN) + + +@pytest.fixture(scope="session") +def app_url(tmp_path_factory, worker_id): + """Start the Vite dev server and return its URL. + + When running under pytest-xdist, only the first worker (gw0) starts the + server and writes the URL to a shared temp file. All other workers wait + for that file to appear and then read the URL from it. The server is only + stopped once every worker has finished (tracked via a shared counter file). + When running without xdist (worker_id == "master") the behaviour is + identical to the original single-worker case. + + Shared files are placed inside tmp_path_factory.getbasetemp() so that two + concurrent pytest invocations never collide (each invocation gets a unique + basetemp directory from pytest's per-run counter). + """ + basetemp = tmp_path_factory.getbasetemp() + # In xdist each worker gets its own subdirectory; step up to the shared parent + if worker_id != "master": + basetemp = basetemp.parent + server_url_file = str(basetemp / "trainus_server_url") + worker_count_file = str(basetemp / "trainus_worker_count") + worker_count_lock = str(basetemp / "trainus_worker_count.lock") + + url = "http://localhost:5199" + is_controller = worker_id in ("master", "gw0") + + # Register this worker in the shared counter + _counter_add(1, worker_count_file, worker_count_lock) + + server_error_file = str(basetemp / "trainus_server_error") + + if is_controller: + # Remove stale files from a previous run if present + for stale in (server_url_file, server_error_file): + try: + os.remove(stale) + except FileNotFoundError: + pass + + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + process = subprocess.Popen( + ["npm", "run", "dev", "--", "--port", "5199", "--strictPort"], + cwd=project_root, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + preexec_fn=os.setsid, + ) + + # Wait for the server to be ready (up to 60 s) + for _ in range(120): + try: + urllib.request.urlopen(url, timeout=1) + break + except Exception: + time.sleep(0.5) + else: + process.kill() + # Signal secondary workers to stop waiting + with open(server_error_file, "w") as f: + f.write("Vite dev server failed to start within 60s") + raise RuntimeError("Vite dev server failed to start within 60s") + + # Publish the URL so other workers can pick it up + with open(server_url_file, "w") as f: + f.write(url) + + yield url + + # Mark the controller itself as done, then wait for all other workers + # to finish before killing the server. We decrement exactly once here + # and then only *read* the counter so we never accidentally modify it + # inside the polling loop. + _counter_add(-1, worker_count_file, worker_count_lock) + for _ in range(240): + if _counter_read(worker_count_file, worker_count_lock) <= 0: + break + time.sleep(0.5) + + # Cleanup + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + + else: + # Secondary workers: wait until gw0 has written the URL file (up to 90 s) + for _ in range(180): + if os.path.exists(server_url_file): + break + if os.path.exists(server_error_file): + with open(server_error_file) as f: + msg = f.read().strip() + raise RuntimeError(f"gw0 failed to start the Vite dev server: {msg}") + time.sleep(0.5) + else: + raise RuntimeError("Timed out waiting for Vite dev server URL from gw0") + + with open(server_url_file) as f: + server_url = f.read().strip() + + yield server_url + + # Signal this worker is done + _counter_add(-1, worker_count_file, worker_count_lock) + + +@pytest.fixture(scope="session") +def browser_type_launch_args(): + """Configure browser launch args.""" + return {} + + +@pytest.fixture(scope="session") +def browser_context_args(): + """Configure browser context.""" + return { + "viewport": {"width": 1280, "height": 720}, + } diff --git a/tests/fixtures/cross_user_full.json b/tests/fixtures/cross_user_full.json new file mode 100644 index 0000000..7178d73 --- /dev/null +++ b/tests/fixtures/cross_user_full.json @@ -0,0 +1,120 @@ +{ + "metadata": { + "appName": "trainus", + "appVersion": "1.0.0", + "schemaVersion": 1, + "exportDate": "2024-01-01T00:00:00.000Z", + "exportedBy": { "name": "Fixture User", "uuid": "facade00-0000-4000-a000-000000000001" } + }, + "data": { + "exercises": [ + { + "id": "facade00-0000-4000-a000-e00000000001", + "title": "Fixture Exercise One", + "description": "", + "asymmetric": false, + "alternate": false, + "image_urls": [], + "video_urls": [], + "default_reps": 10, + "default_weight": 0, + "created_by": "facade00-0000-4000-a000-000000000001" + }, + { + "id": "facade00-0000-4000-a000-e00000000002", + "title": "Fixture Exercise Two", + "description": "", + "asymmetric": false, + "alternate": false, + "image_urls": [], + "video_urls": [], + "default_reps": 8, + "default_weight": 20, + "created_by": "facade00-0000-4000-a000-000000000001" + } + ], + "combos": [ + { + "id": "facade00-0000-4000-a000-c00000000001", + "title": "Fixture Combo", + "description": "", + "type": "NONE", + "timer_config": {}, + "created_by": "facade00-0000-4000-a000-000000000001", + "exercises": [ + { + "exercise_id": "facade00-0000-4000-a000-e00000000001", + "position": 0, + "default_reps": 5, + "default_weight": 10 + }, + { + "exercise_id": "facade00-0000-4000-a000-e00000000002", + "position": 1, + "default_reps": 8, + "default_weight": 20 + } + ] + } + ], + "sessions": [ + { + "id": "facade00-0000-4000-a000-500000000001", + "title": "Fixture Session", + "description": "", + "created_by": "facade00-0000-4000-a000-000000000001", + "items": [ + { + "position": 0, + "item_id": "facade00-0000-4000-a000-e00000000001", + "item_type": "exercise", + "repetitions": 10, + "weight": 0 + }, + { + "position": 1, + "item_id": "facade00-0000-4000-a000-c00000000001", + "item_type": "combo", + "repetitions": 3, + "weight": 0 + } + ] + } + ], + "collections": [ + { + "id": "facade00-0000-4000-a000-d00000000001", + "label": "Fixture Collection", + "created_by": "facade00-0000-4000-a000-000000000001", + "sessions": [ + { + "position": 0, + "session_id": "facade00-0000-4000-a000-500000000001" + } + ] + } + ], + "executionLogs": [ + { + "id": "facade00-0000-4000-a000-100000000001", + "session_id": "facade00-0000-4000-a000-500000000001", + "started_at": "2024-01-01T10:00:00.000Z", + "finished_at": "2024-01-01T10:30:00.000Z", + "items": [ + { + "id": "facade00-0000-4000-a000-100000000011", + "log_id": "facade00-0000-4000-a000-100000000001", + "exercise_id": "facade00-0000-4000-a000-e00000000001", + "combo_id": null, + "round_number": 1, + "reps_done": 10, + "weight_used": 50, + "side": null, + "completed": true, + "timestamp": "2024-01-01T10:05:00.000Z" + } + ] + } + ] + } +} diff --git a/tests/fixtures/cross_user_id_collision.json b/tests/fixtures/cross_user_id_collision.json new file mode 100644 index 0000000..57bd849 --- /dev/null +++ b/tests/fixtures/cross_user_id_collision.json @@ -0,0 +1,43 @@ +{ + "metadata": { + "appName": "trainus", + "appVersion": "1.0.0", + "schemaVersion": 1, + "exportDate": "2024-01-01T00:00:00.000Z", + "exportedBy": { "name": "Other User", "uuid": "other-user-uuid-collision-999" } + }, + "data": { + "exercises": [ + { + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "title": "Imported Exercise", + "description": "", + "asymmetric": false, + "alternate": false, + "image_urls": [], + "video_urls": [], + "default_reps": 10, + "default_weight": 0, + "created_by": "other-user-uuid-collision-999" + } + ], + "combos": [ + { + "id": "11111111-2222-3333-4444-555555555555", + "title": "Imported Combo", + "description": "", + "type": "NONE", + "timer_config": {}, + "created_by": "other-user-uuid-collision-999", + "exercises": [ + { + "exercise_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "position": 0, + "default_reps": 5, + "default_weight": 10 + } + ] + } + ] + } +} diff --git a/tests/pytest.ini b/tests/pytest.ini new file mode 100644 index 0000000..051d1ff --- /dev/null +++ b/tests/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +base_url = http://localhost:5199 diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..8bc264c --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,4 @@ +playwright>=1.40.0 +pytest>=7.4.0 +pytest-playwright>=0.4.0 +pytest-xdist>=3.5.0 diff --git a/tests/run_parallel.sh b/tests/run_parallel.sh new file mode 100755 index 0000000..e763815 --- /dev/null +++ b/tests/run_parallel.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Run the Playwright test suite in parallel using pytest-xdist. +# +# Usage: +# ./run_parallel.sh # both browsers, auto workers +# ./run_parallel.sh --browser firefox # override browser selection +# ./run_parallel.sh test_step3_exercises.py # single file, both browsers +# +# Environment variables: +# PYTEST_WORKERS number of parallel workers (default: auto = one per CPU core) +# PYTEST_BROWSERS space-separated list of browsers (default: "firefox chromium") +# +# Examples: +# PYTEST_WORKERS=4 ./run_parallel.sh +# PYTEST_WORKERS=2 PYTEST_BROWSERS="firefox" ./run_parallel.sh + +set -euo pipefail + +cd "$(dirname "$0")" + +WORKERS="${PYTEST_WORKERS:-auto}" +BROWSERS="${PYTEST_BROWSERS:-firefox chromium}" + +# Build --browser flags from the BROWSERS variable +BROWSER_FLAGS=() +for b in $BROWSERS; do + BROWSER_FLAGS+=(--browser "$b") +done + +.venv/bin/python -m pytest \ + "${BROWSER_FLAGS[@]}" \ + --dist loadfile \ + -n "$WORKERS" \ + -v \ + "$@" diff --git a/tests/test_about.py b/tests/test_about.py new file mode 100644 index 0000000..3a107f4 --- /dev/null +++ b/tests/test_about.py @@ -0,0 +1,142 @@ +""" +About Dialog Tests +- test_about_button_visible: The About TrainUs button is visible on the Settings page +- test_about_dialog_opens: Clicking the button opens the About modal +- test_about_dialog_shows_app_version: Application version is displayed and non-empty +- test_about_dialog_shows_db_version: Database version is displayed and non-empty +- test_about_dialog_shows_author: Author name is displayed +- test_about_dialog_shows_links: Repository and website links are present +- test_about_notice_visible: Notice text is displayed inline in the About dialog +- test_about_license_btn_opens_dialog: View License button opens the license modal on top of About +- test_about_dialog_closes: The modal can be closed with the close button +- test_about_dialog_labels_french: Labels are translated when language is French +""" +from playwright.sync_api import Page, expect + + +def _wait_for_db(page: Page, app_url: str): + """Navigate and wait for the database to be ready.""" + page.goto(app_url) + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + +def _go_to_settings(page: Page, app_url: str): + """Navigate to the settings page and wait for DB.""" + _wait_for_db(page, app_url) + page.click('a[href="#/settings"]') + page.wait_for_selector('[data-testid="settings-language"]') + + +def _open_about(page: Page, app_url: str): + """Navigate to settings and open the About dialog.""" + _go_to_settings(page, app_url) + page.click('[data-testid="settings-about"]') + page.wait_for_selector('[data-testid="modal-card"]') + + +def test_about_button_visible(page: Page, app_url: str): + """The About TrainUs button is visible on the Settings page.""" + _go_to_settings(page, app_url) + expect(page.locator('[data-testid="settings-about"]')).to_be_visible() + + +def test_about_dialog_opens(page: Page, app_url: str): + """Clicking the About button opens the modal dialog.""" + _open_about(page, app_url) + expect(page.locator('[data-testid="modal-card"]')).to_be_visible() + expect(page.locator('[data-testid="modal-title"]')).to_be_visible() + + +def test_about_dialog_shows_app_version(page: Page, app_url: str): + """Application version field is visible and contains a non-empty value.""" + _open_about(page, app_url) + version_el = page.locator('[data-testid="about-app-version"]') + expect(version_el).to_be_visible() + version_text = version_el.inner_text().strip() + assert version_text, "Application version should not be empty" + # Version follows semver pattern (e.g. 1.0.0) + import re + assert re.match(r'^\d+\.\d+\.\d+', version_text), ( + f"Application version '{version_text}' does not look like a semver string" + ) + + +def test_about_dialog_shows_db_version(page: Page, app_url: str): + """Database version field is visible and contains a non-empty numeric value.""" + _open_about(page, app_url) + db_version_el = page.locator('[data-testid="about-db-version"]') + expect(db_version_el).to_be_visible() + db_version_text = db_version_el.inner_text().strip() + assert db_version_text, "Database version should not be empty" + assert db_version_text.isdigit(), ( + f"Database version '{db_version_text}' should be a numeric value" + ) + + +def test_about_dialog_shows_author(page: Page, app_url: str): + """Author name 'David Florance' is displayed in the About dialog.""" + _open_about(page, app_url) + expect(page.locator('[data-testid="modal-card"]')).to_contain_text("David Florance") + + +def test_about_dialog_shows_links(page: Page, app_url: str): + """Repository and website links are present in the About dialog.""" + _open_about(page, app_url) + expect(page.locator('[data-testid="about-repo-link"]')).to_be_visible() + expect(page.locator('[data-testid="about-website-link"]')).to_be_visible() + + +def test_about_notice_visible(page: Page, app_url: str): + """Notice text is displayed inline in the About dialog.""" + _open_about(page, app_url) + notice = page.locator('[data-testid="about-notice"]') + expect(notice).to_be_visible() + expect(notice).to_contain_text("TrainUs") + + +def test_about_license_btn_opens_dialog(page: Page, app_url: str): + """Clicking View License opens the license modal on top of the About dialog.""" + _open_about(page, app_url) + btn = page.locator('[data-testid="about-license-btn"]') + expect(btn).to_be_visible() + expect(btn).to_be_enabled() + btn.click() + expect(page.locator('[data-testid="license-dialog-content"]')).to_be_visible() + expect(page.locator('[data-testid="about-app-version"]')).to_be_visible() + + +def test_about_dialog_closes(page: Page, app_url: str): + """The About modal can be dismissed with the close button.""" + _open_about(page, app_url) + page.click('[data-testid="modal-close"]') + expect(page.locator('[data-testid="modal-card"]')).not_to_be_visible() + + +def test_about_dialog_labels_french(page: Page, app_url: str): + """About dialog labels are translated when the language is set to French.""" + _go_to_settings(page, app_url) + + # Switch to French + page.select_option('[data-testid="settings-language"]', 'fr') + page.wait_for_function( + "window.__repos.settings.get('language').then(v => v === 'fr')", + timeout=5000, + ) + + page.click('[data-testid="settings-about"]') + page.wait_for_selector('[data-testid="modal-card"]') + + expect(page.locator('[data-testid="modal-title"]')).to_have_text("À propos de TrainUs") + expect(page.locator('[data-testid="modal-card"]')).to_contain_text("Version de l'application") + expect(page.locator('[data-testid="modal-card"]')).to_contain_text("Version de la base de données") + + # Reset to English + page.click('[data-testid="modal-close"]') + page.select_option('[data-testid="settings-language"]', 'en') + page.wait_for_function( + "window.__repos.settings.get('language').then(v => v === 'en')", + timeout=5000, + ) diff --git a/tests/test_license.py b/tests/test_license.py new file mode 100644 index 0000000..a4fece8 --- /dev/null +++ b/tests/test_license.py @@ -0,0 +1,59 @@ +""" +License Dialog Tests +- test_license_dialog_opens_from_about: Clicking View License in About opens the license modal +- test_license_dialog_contains_agpl_text: The modal displays the GNU AGPL v3 heading +- test_license_dialog_closes: The license modal can be closed independently +- test_about_remains_open_after_license_close: Closing the license dialog keeps About open +""" +from playwright.sync_api import Page, expect + + +def _wait_for_db(page: Page, app_url: str): + """Navigate and wait for the database to be ready.""" + page.goto(app_url) + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + +def _open_license(page: Page, app_url: str): + """Navigate to Settings, open About, then open the license dialog.""" + _wait_for_db(page, app_url) + page.click('a[href="#/settings"]') + page.wait_for_selector('[data-testid="settings-language"]') + page.click('[data-testid="settings-about"]') + page.wait_for_selector('[data-testid="modal-card"]') + page.click('[data-testid="about-license-btn"]') + page.wait_for_selector('[data-testid="license-dialog-content"]') + + +def test_license_dialog_opens_from_about(page: Page, app_url: str): + """Clicking View License in the About dialog opens the license modal.""" + _open_license(page, app_url) + expect(page.locator('[data-testid="license-dialog-content"]')).to_be_visible() + + +def test_license_dialog_contains_agpl_text(page: Page, app_url: str): + """The license modal displays the GNU Affero General Public License heading.""" + _open_license(page, app_url) + expect(page.locator('[data-testid="license-dialog-content"]')).to_contain_text( + "GNU AFFERO GENERAL PUBLIC LICENSE" + ) + + +def test_license_dialog_closes(page: Page, app_url: str): + """The license modal can be closed with its own close button.""" + _open_license(page, app_url) + modals = page.locator('[data-testid="modal-close"]') + modals.last.click() + expect(page.locator('[data-testid="license-dialog-content"]')).not_to_be_visible() + + +def test_about_remains_open_after_license_close(page: Page, app_url: str): + """Closing the license dialog leaves the About dialog still open.""" + _open_license(page, app_url) + modals = page.locator('[data-testid="modal-close"]') + modals.last.click() + expect(page.locator('[data-testid="license-dialog-content"]')).not_to_be_visible() + expect(page.locator('[data-testid="about-app-version"]')).to_be_visible() diff --git a/tests/test_step0_scaffolding.py b/tests/test_step0_scaffolding.py new file mode 100644 index 0000000..26d5dfa --- /dev/null +++ b/tests/test_step0_scaffolding.py @@ -0,0 +1,94 @@ +""" +Step 0 — Scaffolding Tests +- test_app_loads: App loads and renders the shell +- test_nav_routes: All navigation links route correctly +- test_actionbar_shows_title: Action bar displays the current page title +- test_responsive_mobile: Navbar moves to bottom on mobile viewport +- test_i18n_keys_render: i18n keys render as English text (not raw keys) +""" +import re +from playwright.sync_api import Page, expect + + +def test_app_loads(page: Page, app_url: str): + """App loads and renders the navbar with the app name.""" + page.goto(app_url) + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'" + " || document.documentElement.getAttribute('data-db-error') !== null", + timeout=15000, + ) + expect(page.locator(".navbar-brand")).to_have_text("TrainUs") + expect(page.locator(".actionbar")).to_be_visible() + + +def test_nav_routes(page: Page, app_url: str): + """Clicking each nav link navigates to the correct route.""" + page.goto(app_url) + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + routes = { + "Home": "#/home", + "Exercises": "#/exercises", + "Combos": "#/combos", + "Sessions": "#/sessions", + "Statistics": "#/stats", + "Settings": "#/settings", + } + + for label, expected_hash in routes.items(): + page.locator(f".navbar a:has-text('{label}')").click() + page.wait_for_url(f"**/{expected_hash}") + expect(page).to_have_url(re.compile(re.escape(expected_hash))) + + +def test_actionbar_shows_title(page: Page, app_url: str): + """Action bar h1 reflects the current page name.""" + page.goto(f"{app_url}/#/exercises") + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + expect(page.locator(".actionbar h1")).to_have_text("Exercises") + + page.goto(f"{app_url}/#/settings") + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + expect(page.locator(".actionbar h1")).to_have_text("Settings") + + +def test_responsive_mobile(page: Page, app_url: str): + """On a narrow viewport, navbar should be at the bottom.""" + page.set_viewport_size({"width": 375, "height": 667}) + page.goto(app_url) + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + navbar = page.locator(".navbar") + expect(navbar).to_be_visible() + + # Brand should be hidden on mobile + brand = page.locator(".navbar-brand") + expect(brand).to_be_hidden() + + +def test_i18n_keys_render(page: Page, app_url: str): + """i18n renders actual text, not raw keys like 'nav.home'.""" + page.goto(app_url) + page.wait_for_function( + "document.documentElement.getAttribute('data-db-ready') === 'true'", + timeout=15000, + ) + + # Check that nav links contain real words, not i18n keys + nav_text = page.locator(".navbar").inner_text() + assert "nav.home" not in nav_text, "i18n keys not resolved" + assert "Home" in nav_text + assert "Settings" in nav_text diff --git a/tests/test_step10_pwa.py b/tests/test_step10_pwa.py new file mode 100644 index 0000000..137bcaa --- /dev/null +++ b/tests/test_step10_pwa.py @@ -0,0 +1,123 @@ +""" +Step 10 — PWA Finalization & Polish tests. + +Tests manifest presence and validity, service worker registration, +icon availability, and install prompt absence in test environment. +""" + +import json +import pytest + + +def test_manifest_link_present(page, app_url): + """HTML page includes a pointing to /manifest.json.""" + page.goto(app_url) + handle = page.query_selector('link[rel="manifest"]') + assert handle is not None, " not found in " + href = handle.get_attribute("href") + assert href == "/manifest.json", f"Unexpected manifest href: {href}" + + +def test_manifest_json_valid(page, app_url): + """manifest.json is reachable, valid JSON, and has required PWA fields.""" + response = page.request.get(f"{app_url}/manifest.json") + assert response.status == 200, f"manifest.json returned {response.status}" + manifest = json.loads(response.text()) + assert manifest.get("name"), "manifest missing 'name'" + assert manifest.get("display") == "standalone", "manifest display should be 'standalone'" + icons = manifest.get("icons", []) + assert len(icons) >= 2, "manifest should declare at least 2 icons" + sizes = {icon["sizes"] for icon in icons} + assert "192x192" in sizes, "192x192 icon missing from manifest" + assert "512x512" in sizes, "512x512 icon missing from manifest" + + +def test_icon_192_accessible(page, app_url): + """icon-192.png is served with a 200 status.""" + response = page.request.get(f"{app_url}/icon-192.png") + assert response.status == 200, f"icon-192.png returned {response.status}" + + +def test_icon_512_accessible(page, app_url): + """icon-512.png is served with a 200 status.""" + response = page.request.get(f"{app_url}/icon-512.png") + assert response.status == 200, f"icon-512.png returned {response.status}" + + +def test_service_worker_registered(page, app_url): + """Service worker is registered in the browser after page load.""" + page.goto(app_url) + page.wait_for_selector('[data-testid="db-loading"]', state="detached", timeout=15000) + registration = page.evaluate( + "() => navigator.serviceWorker.getRegistration().then(r => !!r)" + ) + assert registration, "No service worker registration found" + + +def test_install_prompt_hidden_by_default(page, app_url): + """Install prompt button is not shown in the test environment (beforeinstallprompt not fired).""" + page.goto(app_url) + page.wait_for_selector('[data-testid="db-loading"]', state="detached", timeout=15000) + btn = page.query_selector('[data-testid="install-prompt-btn"]') + assert btn is None, "Install prompt button should not appear without beforeinstallprompt" + + +def test_favicon_linked(page, app_url): + """HTML page includes a for the favicon.""" + page.goto(app_url) + handle = page.query_selector('link[rel="icon"]') + assert handle is not None, " not found in " + + +def test_apple_touch_icon_linked(page, app_url): + """HTML page includes a .""" + page.goto(app_url) + handle = page.query_selector('link[rel="apple-touch-icon"]') + assert handle is not None, " not found in " + + +def test_nav_has_aria_label(page, app_url): + """Main navigation has an aria-label for screen readers.""" + page.goto(app_url) + page.wait_for_selector('[data-testid="db-loading"]', state="detached", timeout=15000) + nav = page.query_selector("nav.navbar") + assert nav is not None, "