diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..abffd7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm run test + - run: npm run build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e942b9d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run build + - name: Bundle dist + run: tar -czf faturlens-dist-${{ github.ref_name }}.tar.gz -C dist . + - uses: actions/upload-artifact@v4 + with: + name: faturlens-dist + path: faturlens-dist-${{ github.ref_name }}.tar.gz + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: faturlens-dist-${{ github.ref_name }}.tar.gz + generate_release_notes: true diff --git a/README.md b/README.md index fd6af3d..aa39c63 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,47 @@ # Faturlens [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE) +[![CI](https://github.com/YASSERRMD/Faturlens/actions/workflows/ci.yml/badge.svg)](https://github.com/YASSERRMD/Faturlens/actions/workflows/ci.yml) Browser-native invoice OCR and structured extraction. A transformer vision-language model (`LiquidAI/LFM2.5-VL-1.6B-ONNX`) runs **fully client-side** via WebGPU, with a WASM CPU fallback. No server, no API, no data leaves the -machine — the app is offline-first after the one-time model download. +machine — the app is offline-first (installable PWA) after the one-time model +download. ## Architecture -``` -┌──────────────────────────────────────────────────────────────────┐ -│ Main thread (UI) │ -│ capability ─► model gate ─► ingest ─► review ─► export │ -└───────────────┬───────────────────────────────────┬──────────────┘ - │ ImageBitmaps / prompts │ tokens / stats - ▼ ▲ -┌──────────────────────────────────────────────────────────────────┐ -│ Inference Web Worker │ -│ Cache API bytes ─► ORT sessions (WebGPU | WASM) │ -│ Pass 1: full markdown transcription │ -│ Pass 2: schema-constrained JSON extraction │ -└──────────────────────────────────────────────────────────────────┘ - │ extraction - ▼ - Deterministic validation layer (pure TS, zero ML) ─► human review -``` +![Architecture](docs/architecture.svg) + +A two-pass pipeline runs entirely in a dedicated Web Worker: **Pass 1** +transcribes the whole invoice to Markdown; **Pass 2** extracts schema-constrained +JSON. A deterministic, zero-ML validation layer then gates every result and +routes anything suspect to human review. -> Diagram is a placeholder; a committed SVG lands in Phase 13. +## Feature overview + +- Drag-in PNG / JPEG / WebP / PDF (≤25MB, ≤20 PDF pages), EXIF-corrected, tiled. +- Client-side VLM inference (WebGPU, WASM fallback) in an isolated worker. +- Confidence-annotated extraction with a deterministic validation layer (TRN/VAT, + arithmetic, dates, currency). +- Split review UI: zoomable page image, live re-validation on edit, approval + gating, edit audit trail. +- Export to canonical JSON (with provenance), CSV (header + line items), clipboard + TSV, or a batch zip — gated on approval. +- Batch queue + IndexedDB persistence; sessions restore with no network and no + reprocessing. +- Installable, offline-first PWA. ## Hardware targets -| Path | Hardware | Notes | -| -------- | --------------------------------------------------- | ----------------------------- | -| Primary | 16GB RAM laptops, integrated GPU (Iris Xe / Radeon) | WebGPU execution provider | -| Fallback | CPU-only browsers | WASM EP, reduced token budget | +| Path | Hardware | Throughput (measure on your machine) | +| -------- | --------------------------------------------------- | ------------------------------------ | +| Primary | 16GB RAM laptops, integrated GPU (Iris Xe / Radeon) | WebGPU EP — seconds per page | +| Fallback | CPU-only browsers | WASM EP — minutes per page | + +> Throughput is reported live in the app's stats drawer; the warmup benchmark +> shows an upfront per-page estimate before processing. (Populate this table with +> your reference numbers from a real run.) The browser tab memory ceiling is treated as a hard 4GB budget; the app aborts gracefully beyond it. @@ -43,21 +50,33 @@ gracefully beyond it. Zero network calls after the model download, except explicit Hugging Face CDN fetches during caching. No telemetry, no analytics, no external fonts, no -runtime CDN scripts. +runtime CDN scripts. The service worker never intercepts HF CDN requests. ## Development ```bash -npm ci # install -npm run dev # start the dev server +npm ci +npm run dev # dev server npm run typecheck npm run lint npm run test npm run build +npm run ci # all of the above ``` Requires Node 22 (see `.nvmrc`). +### Dev/diagnostic flags (query params) + +- `?diag` — device capability report card. +- `?harness` — inference dev harness (prompt + image, streaming output, stats). +- `?model` — force the model download gate. + +## Releasing + +CI runs typecheck/lint/test/build on every PR. Pushing a `v*` tag builds and +attaches the `dist` bundle to a GitHub release (`.github/workflows/release.yml`). + ## License [Apache-2.0](./LICENSE) diff --git a/docs/architecture.svg b/docs/architecture.svg new file mode 100644 index 0000000..d666779 --- /dev/null +++ b/docs/architecture.svg @@ -0,0 +1,58 @@ + + + Faturlens architecture + + + + Main thread (UI) + + + + + + + + + capability + model gate + ingest + tile + review + export + + + + + Inference Web Worker (ORT: WebGPU | WASM) + + + + + + + Cache API bytes → + ORT sessions + Pass 1 + markdown transcription + Pass 2 + schema-constrained JSON + + + + + + + + images / prompts + tokens / stats + + + + Deterministic validation (pure TS, zero ML) → human review + + + + + + + + diff --git a/package-lock.json b/package-lock.json index 7148dfa..35852cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,14 @@ "license": "Apache-2.0", "dependencies": { "@huggingface/transformers": "^3.8.1", + "dexie": "^4.4.3", + "dexie-react-hooks": "^1.1.7", + "fflate": "^0.8.3", "onnxruntime-web": "^1.26.0", "pdfjs-dist": "^4.10.38", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -23,18 +27,21 @@ "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "axe-core": "^4.12.0", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", + "fake-indexeddb": "^6.2.5", "globals": "^15.14.0", "jsdom": "^25.0.1", "prettier": "^3.4.2", "typescript": "^5.7.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.7", + "vite-plugin-pwa": "^0.21.2", "vitest": "^3.0.0" }, "engines": { @@ -143,142 +150,1344 @@ "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==", + "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-define-polyfill-provider/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/@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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "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-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "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" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-globals": { + "node_modules/@babel/plugin-transform-regenerator": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "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/helper-module-imports": { + "node_modules/@babel/plugin-transform-regexp-modifiers": { "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==", + "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/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "@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/helper-module-transforms": { + "node_modules/@babel/plugin-transform-reserved-words": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "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-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/helper-plugin-utils": { + "node_modules/@babel/plugin-transform-shorthand-properties": { "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==", + "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/helper-string-parser": { + "node_modules/@babel/plugin-transform-spread": { "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==", + "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/helper-validator-identifier": { + "node_modules/@babel/plugin-transform-sticky-regex": { "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==", + "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/helper-validator-option": { + "node_modules/@babel/plugin-transform-template-literals": { "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==", + "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/helpers": { + "node_modules/@babel/plugin-transform-typeof-symbol": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "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/template": "^7.29.7", - "@babel/types": "^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/parser": { + "node_modules/@babel/plugin-transform-unicode-escapes": { "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==", + "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/types": "^7.29.7" + "@babel/helper-plugin-utils": "^7.29.7" }, - "bin": { - "parser": "bin/babel-parser.js" + "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.0.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { + "node_modules/@babel/plugin-transform-unicode-regex": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "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": { @@ -288,22 +1497,124 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { + "node_modules/@babel/plugin-transform-unicode-sets-regex": { "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "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.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -1661,6 +2972,16 @@ "url": "https://opencollective.com/libvips" } }, + "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/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -1705,6 +3026,17 @@ "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", @@ -2026,49 +3358,198 @@ "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", - "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", - "license": "BSD-3-Clause", + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "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-node-resolve/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/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": { - "@protobufjs/aspromise": "^1.1.1" + "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/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", - "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "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/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "node_modules/@rollup/pluginutils/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==", "dev": true, "license": "MIT" }, @@ -2519,6 +4000,22 @@ "@testing-library/dom": ">=7.21.4" } }, + "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/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -2634,14 +4131,12 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.31", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "dev": true, "license": "MIT", "peer": true, "dependencies": { @@ -2660,6 +4155,20 @@ "@types/react": "^18.0.0" } }, + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.60.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.1.tgz", @@ -3631,6 +5140,13 @@ "node": ">=12" } }, + "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", @@ -3648,6 +5164,16 @@ "dev": true, "license": "MIT" }, + "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", @@ -3664,6 +5190,58 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.12.0.tgz", + "integrity": "sha512-FTavr/7Ba0IptwGOPxnQvdyW2tAsdLBMTBXz7rKH6xJ2skpyxpBxyHkDdBs4lf69yRqYpkqCdfhnwS8YULGOmg==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "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": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -3737,6 +5315,13 @@ "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/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -3914,6 +5499,23 @@ "node": ">= 0.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/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3928,6 +5530,20 @@ "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", @@ -3943,6 +5559,16 @@ "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/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -3975,7 +5601,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -4088,6 +5713,16 @@ "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", @@ -4157,6 +5792,24 @@ "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", "license": "MIT" }, + "node_modules/dexie": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.4.3.tgz", + "integrity": "sha512-N+3IGQ3HPlyO2YAkntGAwitm42BpBGV86MttzUMiRzWLa4NGh0pltVRcUVF4ybL/OnXjCrr9k7SDPIKkFYP2Lg==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/dexie-react-hooks": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/dexie-react-hooks/-/dexie-react-hooks-1.1.7.tgz", + "integrity": "sha512-Lwv5W0Hk+uOW3kGnsU9GZoR1er1B7WQ5DSdonoNG+focTNeJbHW6vi6nBoX534VKI3/uwHebYzSw1fwY6a7mTw==", + "license": "Apache-2.0", + "peerDependencies": { + "@types/react": ">=16", + "dexie": "^3.2 || ^4.0.1-alpha", + "react": ">=16" + } + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -4192,6 +5845,22 @@ "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.368", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.368.tgz", @@ -4767,6 +6436,19 @@ "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/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -4777,6 +6459,16 @@ "node": ">=12.0.0" } }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4798,6 +6490,23 @@ "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", @@ -4816,6 +6525,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -4829,6 +6544,39 @@ "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/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", @@ -4889,6 +6637,23 @@ "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/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -4906,6 +6671,22 @@ "node": ">= 6" } }, + "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", @@ -5007,6 +6788,13 @@ "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", @@ -5049,7 +6837,32 @@ "resolve-pkg-maps": "^1.0.0" }, "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "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": { @@ -5065,6 +6878,45 @@ "node": ">=10.13.0" } }, + "node_modules/glob/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/glob/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/glob/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/global-agent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", @@ -5135,6 +6987,13 @@ "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/guid-typescript": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", @@ -5288,6 +7147,13 @@ "node": ">=0.10.0" } }, + "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", @@ -5580,6 +7446,13 @@ "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", @@ -5610,6 +7483,16 @@ "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-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -5636,6 +7519,16 @@ "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", @@ -5665,6 +7558,19 @@ "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", @@ -5776,6 +7682,40 @@ "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", @@ -5900,6 +7840,29 @@ "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", @@ -5910,6 +7873,16 @@ "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", @@ -5940,6 +7913,13 @@ "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.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5947,6 +7927,13 @@ "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/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -6409,6 +8396,13 @@ "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/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6462,6 +8456,33 @@ "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.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6504,7 +8525,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6583,6 +8603,19 @@ "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/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -6726,6 +8759,26 @@ "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", @@ -6747,6 +8800,54 @@ "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": "2.0.0-next.7", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", @@ -6975,6 +9076,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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", @@ -7186,6 +9297,43 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/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", @@ -7196,6 +9344,56 @@ "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/source-map/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/source-map/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/source-map/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/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", @@ -7223,18 +9421,46 @@ "dev": true, "license": "MIT" }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "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", - "internal-slot": "^1.1.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": { @@ -7297,6 +9523,21 @@ "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-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -7307,6 +9548,16 @@ "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/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -7411,6 +9662,68 @@ "node": ">=18" } }, + "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/tempy/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/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", + "peer": true, + "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/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -7731,6 +10044,73 @@ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "license": "MIT" }, + "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/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", @@ -7769,6 +10149,17 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, + "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", @@ -7909,6 +10300,37 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-pwa": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz", + "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.6", + "pretty-bytes": "^6.1.1", + "tinyglobby": "^0.2.10", + "workbox-build": "^7.3.0", + "workbox-window": "^7.3.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vite-pwa/assets-generator": "^0.2.6", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "workbox-build": "^7.3.0", + "workbox-window": "^7.3.0" + }, + "peerDependenciesMeta": { + "@vite-pwa/assets-generator": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.6.tgz", @@ -8175,6 +10597,268 @@ "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/@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/workbox-build/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", + "peer": true, + "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/workbox-build/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/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/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", @@ -8233,6 +10917,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index b0b3fd3..3b66517 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,14 @@ }, "dependencies": { "@huggingface/transformers": "^3.8.1", + "dexie": "^4.4.3", + "dexie-react-hooks": "^1.1.7", + "fflate": "^0.8.3", "onnxruntime-web": "^1.26.0", "pdfjs-dist": "^4.10.38", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.17.0", @@ -37,18 +41,21 @@ "@types/react": "^18.3.18", "@types/react-dom": "^18.3.5", "@vitejs/plugin-react": "^4.3.4", + "axe-core": "^4.12.0", "eslint": "^9.17.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.7.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-refresh": "^0.4.16", + "fake-indexeddb": "^6.2.5", "globals": "^15.14.0", "jsdom": "^25.0.1", "prettier": "^3.4.2", "typescript": "^5.7.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.7", + "vite-plugin-pwa": "^0.21.2", "vitest": "^3.0.0" } } diff --git a/public/demo/README.md b/public/demo/README.md index 3f0b4a2..1b46542 100644 --- a/public/demo/README.md +++ b/public/demo/README.md @@ -1,9 +1,15 @@ # demo -Synthetic demo invoices loaded by the first-run welcome screen. +Synthetic demo invoices loaded by the first-run welcome screen. All synthetic — +no real vendor data. -Four invoices: a clean English invoice, a bilingual Arabic–English invoice, a -multi-page PDF, and a deliberately broken-math invoice that showcases the -validation layer. All synthetic — no real vendor data. +- `clean-en.html` — clean English invoice (arithmetic balances). +- `bilingual.html` — Arabic–English bilingual invoice. +- `multipage.html` — multi-page invoice. +- `broken-math.html` — deliberately broken arithmetic to showcase the validation + layer (subtotal and total do not add up → red findings). +- `demo.json` — manifest consumed by the loader. -_Populated in Phase 13._ +The `.html` files are the **sources**; they are rendered to PNG/PDF (the formats +the pipeline ingests) at build time. Keeping the HTML in-repo makes the demos +easy to tweak without binary churn. diff --git a/public/demo/bilingual.html b/public/demo/bilingual.html new file mode 100644 index 0000000..0a489ef --- /dev/null +++ b/public/demo/bilingual.html @@ -0,0 +1,29 @@ + + + + + + + +

الإمارات للتجارة ش.ذ.م.م / Emirates Trading LLC

+
دبي، الإمارات العربية المتحدة · الرقم الضريبي: 100987654300003
+
Invoice # / رقم الفاتورة: INV-AE-7781 · Date / التاريخ: 2026-03-03 · Currency / العملة: AED
+ + + + + + + + +
Description / الوصفQty / الكميةUnit / السعرAmount / المبلغ
خدمات استشارية / Advisory4300.001200.00
توريد معدات / Equipment1800.00800.00
+

Subtotal / المجموع الفرعي: 2000.00 · VAT 5% / ضريبة القيمة المضافة: 100.00 · Total / الإجمالي: 2100.00

+ + diff --git a/public/demo/broken-math.html b/public/demo/broken-math.html new file mode 100644 index 0000000..ac258f8 --- /dev/null +++ b/public/demo/broken-math.html @@ -0,0 +1,26 @@ + + + + + + + +

Initech Solutions

+
TRN: 100555666700003 · Invoice #: INI-9001 · Date: 2026-05-20 · Currency: AED
+ + + + + + + +
DescriptionQtyUnit PriceAmount
Software license3200.00700.00
Onboarding1500.00500.00
+ +

Subtotal: 1000.00 · VAT (5%): 60.00 · Total: 1500.00

+ + diff --git a/public/demo/clean-en.html b/public/demo/clean-en.html new file mode 100644 index 0000000..510de20 --- /dev/null +++ b/public/demo/clean-en.html @@ -0,0 +1,37 @@ + + + + + + + +

Acme Trading FZE

+
+ Dubai, United Arab Emirates · TRN: 100123456700003
+ Invoice #: INV-2026-0042 · Issue date: 2026-01-15 · Due date: 2026-02-14 · Currency: AED +
+ + + + + + + + +
DescriptionQtyUnit PriceAmount
Consulting services10150.001500.00
Support package2250.00500.00
+ + + + +
Subtotal2000.00
VAT (5%)100.00
Total2100.00
+ + diff --git a/public/demo/demo.json b/public/demo/demo.json new file mode 100644 index 0000000..a934108 --- /dev/null +++ b/public/demo/demo.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "note": "Synthetic demo invoices. No real vendor data. HTML sources are rendered to PNG/PDF at build time.", + "invoices": [ + { "id": "clean-en", "title": "Clean English invoice", "source": "clean-en.html" }, + { "id": "bilingual", "title": "Bilingual Arabic–English invoice", "source": "bilingual.html" }, + { "id": "multipage", "title": "Multi-page invoice", "source": "multipage.html" }, + { + "id": "broken-math", + "title": "Broken-math invoice (validation showcase)", + "source": "broken-math.html" + } + ] +} diff --git a/public/demo/multipage.html b/public/demo/multipage.html new file mode 100644 index 0000000..7055fac --- /dev/null +++ b/public/demo/multipage.html @@ -0,0 +1,36 @@ + + + + + + + +
+

Globex Industrial — Page 1

+
Invoice #: GLX-5500 · Date: 2026-04-10 · Currency: USD
+ + + + + + +
DescriptionQtyUnitAmount
Steel beams2075.001500.00
Fasteners1002.50250.00
+
+
+

Globex Industrial — Page 2

+ + + + + +
DescriptionQtyUnitAmount
Coating service1300.00300.00
+

Subtotal: 2050.00 · Tax: 0.00 · Total: 2050.00

+
+ + diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..b5015e5 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/App.tsx b/src/App.tsx index 3f271f5..a05d5c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,10 @@ +import { useCallback } from 'react'; import styles from './App.module.css'; import { DeviceReport } from './ui/DeviceReport.tsx'; +import { Footer } from './ui/Footer.tsx'; import { InferenceHarness } from './ui/InferenceHarness.tsx'; import { ModelDownloadGate } from './ui/ModelDownloadGate.tsx'; +import { Welcome } from './ui/Welcome.tsx'; function useFlag(name: string): boolean { if (typeof window === 'undefined') return false; @@ -13,6 +16,13 @@ export function App(): React.JSX.Element { const model = useFlag('model'); const harness = useFlag('harness'); + const onUpload = useCallback(() => { + document.getElementById('faturlens-file-input')?.click(); + }, []); + const onLoadDemos = useCallback(() => { + void fetch('/demo/demo.json'); + }, []); + const content = (

Faturlens

@@ -20,11 +30,10 @@ export function App(): React.JSX.Element { Browser-native invoice OCR. Fully client-side — no data leaves your machine.

{diag ? : null} - {harness ? : null} + {harness ? : } +
); - // The model gate is opt-in (?model) until later phases wire it into the - // processing flow, so routine page loads do not trigger a multi-GB download. return model ? {content} : content; } diff --git a/src/lib/export/batch.test.ts b/src/lib/export/batch.test.ts new file mode 100644 index 0000000..31e0be5 --- /dev/null +++ b/src/lib/export/batch.test.ts @@ -0,0 +1,46 @@ +import { unzipSync, strFromU8 } from 'fflate'; +import { describe, expect, it } from 'vitest'; +import { buildZip, combinedJson, selectForExport } from './batch.ts'; +import { makeInput } from './export-fixtures.ts'; + +describe('selectForExport', () => { + it('excludes unapproved documents by default', () => { + const inputs = [makeInput({ approved: true }), makeInput({ approved: false })]; + expect(selectForExport(inputs, false)).toHaveLength(1); + }); + it('includes unapproved when the toggle is on', () => { + const inputs = [makeInput({ approved: true }), makeInput({ approved: false })]; + expect(selectForExport(inputs, true)).toHaveLength(2); + }); +}); + +describe('combinedJson', () => { + it('emits an array of envelopes', () => { + const json = JSON.parse(combinedJson([makeInput(), makeInput()])) as unknown[]; + expect(json).toHaveLength(2); + }); +}); + +describe('buildZip', () => { + it('bundles per-invoice JSON plus both CSVs', () => { + const zip = buildZip([makeInput()], { includeUnapproved: false }); + const entries = unzipSync(zip); + const names = Object.keys(entries); + expect(names).toContain('header.csv'); + expect(names).toContain('lines.csv'); + expect(names.some((n) => n.endsWith('.json'))).toBe(true); + }); + + it('respects approval gating in the bundle', () => { + const inputs = [makeInput({ approved: true }), makeInput({ approved: false })]; + const zip = buildZip(inputs, { includeUnapproved: false }); + const jsonEntries = Object.keys(unzipSync(zip)).filter((n) => n.endsWith('.json')); + expect(jsonEntries).toHaveLength(1); + }); + + it('marks unapproved rows when included', () => { + const zip = buildZip([makeInput({ approved: false })], { includeUnapproved: true }); + const header = strFromU8(unzipSync(zip)['header.csv'] ?? new Uint8Array()); + expect(header).toContain('true'); + }); +}); diff --git a/src/lib/export/batch.ts b/src/lib/export/batch.ts new file mode 100644 index 0000000..4558818 --- /dev/null +++ b/src/lib/export/batch.ts @@ -0,0 +1,51 @@ +// Batch export: a combined JSON array, or a zip bundle of per-invoice JSON plus +// header and line CSVs. Export is gated on approval. + +import { strToU8, zipSync } from 'fflate'; +import { headerCsv, linesCsv } from './csv.ts'; +import { uniqueFilename } from './filename.ts'; +import { buildEnvelope, canonicalStringify, exportInvoiceJson, type ExportInput } from './json.ts'; + +/** + * Select which documents to export. Unapproved documents are excluded unless + * `includeUnapproved` is set; the envelope already carries `needsReview`. + */ +export function selectForExport(inputs: ExportInput[], includeUnapproved: boolean): ExportInput[] { + return includeUnapproved ? inputs : inputs.filter((i) => i.approved); +} + +/** Combined JSON array of envelopes (canonical, stable order). */ +export function combinedJson(inputs: ExportInput[]): string { + return canonicalStringify(inputs.map(buildEnvelope)); +} + +export interface ZipOptions { + includeUnapproved: boolean; +} + +/** Build a zip: one JSON per invoice + header.csv + lines.csv. */ +export function buildZip(inputs: ExportInput[], options: ZipOptions): Uint8Array { + const selected = selectForExport(inputs, options.includeUnapproved); + const taken = new Set(); + const files: Record = {}; + + for (const input of selected) { + const name = uniqueFilename( + { + vendorName: input.invoice.vendor.name.value, + invoiceNumber: input.invoice.invoiceNumber.value, + issueDate: input.invoice.issueDate.value, + }, + 'json', + taken, + ); + files[name] = strToU8(exportInvoiceJson(input)); + } + + const invoices = selected.map((i) => i.invoice); + const needsReview = new Map(selected.map((i) => [i.invoice, !i.approved])); + files['header.csv'] = strToU8(headerCsv(invoices, (inv) => needsReview.get(inv) ?? false)); + files['lines.csv'] = strToU8(linesCsv(invoices)); + + return zipSync(files); +} diff --git a/src/lib/export/clipboard.ts b/src/lib/export/clipboard.ts new file mode 100644 index 0000000..5a2749f --- /dev/null +++ b/src/lib/export/clipboard.ts @@ -0,0 +1,37 @@ +// Clipboard helpers: copy a single invoice as JSON or as a TSV table that +// pastes cleanly into Excel. + +import type { InvoiceV1 } from '../../schema/invoice.ts'; +import { exportInvoiceJson, type ExportInput } from './json.ts'; + +/** Render an invoice's line items as a TSV table (header + rows). */ +export function invoiceToTsv(invoice: InvoiceV1): string { + const header = ['description', 'quantity', 'unitPrice', 'amount', 'taxRate']; + const cell = (v: string | number | null): string => + v === null ? '' : String(v).replace(/[\t\n\r]/g, ' '); + const rows = invoice.lineItems.map((line) => + [ + cell(line.description.value), + cell(line.quantity.value), + cell(line.unitPrice.value), + cell(line.amount.value), + cell(line.taxRate?.value ?? null), + ].join('\t'), + ); + return [header.join('\t'), ...rows].join('\n'); +} + +async function write(text: string): Promise { + const clipboard = (globalThis.navigator as Navigator | undefined)?.clipboard; + if (!clipboard) return false; + await clipboard.writeText(text); + return true; +} + +export function copyInvoiceJson(input: ExportInput): Promise { + return write(exportInvoiceJson(input)); +} + +export function copyInvoiceTsv(invoice: InvoiceV1): Promise { + return write(invoiceToTsv(invoice)); +} diff --git a/src/lib/export/csv.test.ts b/src/lib/export/csv.test.ts new file mode 100644 index 0000000..b9ef10d --- /dev/null +++ b/src/lib/export/csv.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { csvField, headerCsv, linesCsv, toCsv } from './csv.ts'; +import { f, makeInvoice, line } from './export-fixtures.ts'; + +describe('csvField', () => { + it('leaves plain values unquoted', () => { + expect(csvField('Acme')).toBe('Acme'); + }); + it('quotes and escapes commas, quotes, and newlines', () => { + expect(csvField('a,b')).toBe('"a,b"'); + expect(csvField('say "hi"')).toBe('"say ""hi"""'); + expect(csvField('line1\nline2')).toBe('"line1\nline2"'); + }); + it('passes Arabic text through unquoted', () => { + expect(csvField('شركة')).toBe('شركة'); + }); +}); + +describe('toCsv', () => { + it('prepends a UTF-8 BOM and uses CRLF', () => { + const out = toCsv([['a', 'b']]); + expect(out.startsWith('')).toBe(true); + expect(out).toContain('a,b\r\n'); + }); +}); + +describe('headerCsv', () => { + it('emits one row per invoice with a needsReview flag', () => { + const out = headerCsv([makeInvoice()], () => true); + const lines = out.trimEnd().split('\r\n'); + expect(lines[0]).toContain('invoiceNumber'); + expect(lines[1]).toContain('INV-001'); + expect(lines[1]).toContain('true'); + }); + + it('keeps Arabic vendor names intact', () => { + const inv = makeInvoice({ vendor: { ...makeInvoice().vendor, name: f('شركة الإمارات') } }); + expect(headerCsv([inv])).toContain('شركة الإمارات'); + }); +}); + +describe('linesCsv', () => { + it('emits one row per line item keyed by invoice number', () => { + const inv = makeInvoice({ lineItems: [line(), line({ description: f('Gadget') })] }); + const rows = linesCsv([inv]).trimEnd().split('\r\n'); + expect(rows).toHaveLength(3); // header + 2 lines + expect(rows[1]).toContain('INV-001'); + expect(rows[2]).toContain('Gadget'); + }); +}); diff --git a/src/lib/export/csv.ts b/src/lib/export/csv.ts new file mode 100644 index 0000000..68c57cf --- /dev/null +++ b/src/lib/export/csv.ts @@ -0,0 +1,87 @@ +// CSV export. RFC 4180 quoting, CRLF line endings, UTF-8 BOM for Excel. + +import type { Field, InvoiceV1 } from '../../schema/invoice.ts'; + +const BOM = ''; +const CRLF = '\r\n'; + +/** Quote a single CSV field per RFC 4180 when it contains , " or newlines. */ +export function csvField(value: string): string { + if (/[",\r\n]/.test(value)) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +export function toCsv(rows: string[][]): string { + const body = rows.map((row) => row.map(csvField).join(',')).join(CRLF); + return BOM + body + CRLF; +} + +function s(field: Field | undefined): string { + return field?.value ?? ''; +} +function n(field: Field | undefined): string { + return field?.value === null || field?.value === undefined ? '' : String(field.value); +} + +const HEADER_COLUMNS = [ + 'invoiceNumber', + 'issueDate', + 'dueDate', + 'vendorName', + 'vendorTRN', + 'buyerName', + 'currency', + 'subtotal', + 'taxAmount', + 'total', + 'paymentTerms', + 'needsReview', +]; + +/** One row per invoice — flattened scalar fields. */ +export function headerCsv( + invoices: InvoiceV1[], + needsReviewFor: (invoice: InvoiceV1, index: number) => boolean = () => false, +): string { + const rows: string[][] = [HEADER_COLUMNS]; + invoices.forEach((inv, i) => { + rows.push([ + s(inv.invoiceNumber), + s(inv.issueDate), + s(inv.dueDate), + s(inv.vendor.name), + s(inv.vendor.trn), + s(inv.buyer?.name), + s(inv.currency), + n(inv.subtotal), + n(inv.taxAmount), + n(inv.total), + s(inv.paymentTerms), + needsReviewFor(inv, i) ? 'true' : 'false', + ]); + }); + return toCsv(rows); +} + +const LINE_COLUMNS = ['invoiceNumber', 'description', 'quantity', 'unitPrice', 'amount', 'taxRate']; + +/** One row per line item, keyed by invoice number. */ +export function linesCsv(invoices: InvoiceV1[]): string { + const rows: string[][] = [LINE_COLUMNS]; + for (const inv of invoices) { + const invoiceNumber = s(inv.invoiceNumber); + for (const line of inv.lineItems) { + rows.push([ + invoiceNumber, + s(line.description), + n(line.quantity), + n(line.unitPrice), + n(line.amount), + n(line.taxRate), + ]); + } + } + return toCsv(rows); +} diff --git a/src/lib/export/export-fixtures.ts b/src/lib/export/export-fixtures.ts new file mode 100644 index 0000000..620cf19 --- /dev/null +++ b/src/lib/export/export-fixtures.ts @@ -0,0 +1,57 @@ +// Shared invoice builders for export tests. +import type { Field, InvoiceV1, LineItem, Party } from '../../schema/invoice.ts'; +import type { ExportInput } from './json.ts'; + +export function f(value: T | null, confidence: Field['confidence'] = 'extracted'): Field { + return { value, confidence }; +} + +export function party(o: Partial = {}): Party { + return { + name: f('Acme FZE'), + address: f('Dubai'), + trn: f('100123456700003'), + phone: f(null, 'missing'), + email: f(null, 'missing'), + ...o, + }; +} + +export function line(o: Partial = {}): LineItem { + return { + description: f('Widget'), + quantity: f(2), + unitPrice: f(50), + amount: f(100), + taxRate: f(5), + ...o, + }; +} + +export function makeInvoice(o: Partial = {}): InvoiceV1 { + return { + vendor: party(), + invoiceNumber: f('INV-001'), + issueDate: f('2026-01-15'), + currency: f('AED'), + lineItems: [line()], + subtotal: f(100), + taxAmount: f(5), + total: f(105), + uncertain: [], + ...o, + }; +} + +export function makeInput(o: Partial = {}): ExportInput { + return { + invoice: makeInvoice(), + verdict: 'clean', + findings: [], + promptVersions: { pass1: 'PASS1_PROMPT_V1', pass2: 'PASS2_PROMPT_V1' }, + edits: [], + approved: true, + exportedAt: '2026-06-07T00:00:00.000Z', + ...o, + }; +} diff --git a/src/lib/export/filename.test.ts b/src/lib/export/filename.test.ts new file mode 100644 index 0000000..fc7bf38 --- /dev/null +++ b/src/lib/export/filename.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { baseFilename, slugify, uniqueFilename } from './filename.ts'; + +describe('slugify', () => { + it('slugifies ASCII names', () => { + expect(slugify('Acme FZE Trading')).toBe('acme-fze-trading'); + }); + it('returns empty for Arabic-only input (caller falls back)', () => { + expect(slugify('شركة')).toBe(''); + }); +}); + +describe('baseFilename', () => { + it('builds vendor_invoice_date', () => { + expect( + baseFilename({ vendorName: 'Acme FZE', invoiceNumber: 'INV-001', issueDate: '2026-01-15' }), + ).toBe('acme-fze_inv-001_2026-01-15'); + }); + it('falls back to "vendor" for Arabic vendor names', () => { + expect( + baseFilename({ + vendorName: 'شركة الإمارات', + invoiceNumber: 'INV-1', + issueDate: '2026-01-15', + }), + ).toBe('vendor_inv-1_2026-01-15'); + }); + it('uses "undated" when the date is missing', () => { + expect(baseFilename({ vendorName: 'Acme', invoiceNumber: '1', issueDate: null })).toBe( + 'acme_1_undated', + ); + }); +}); + +describe('uniqueFilename', () => { + it('suffixes -2, -3 on collision', () => { + const taken = new Set(); + const parts = { vendorName: 'Acme', invoiceNumber: 'INV-1', issueDate: '2026-01-15' }; + expect(uniqueFilename(parts, 'json', taken)).toBe('acme_inv-1_2026-01-15.json'); + expect(uniqueFilename(parts, 'json', taken)).toBe('acme_inv-1_2026-01-15-2.json'); + expect(uniqueFilename(parts, 'json', taken)).toBe('acme_inv-1_2026-01-15-3.json'); + }); +}); diff --git a/src/lib/export/filename.ts b/src/lib/export/filename.ts new file mode 100644 index 0000000..591f6ba --- /dev/null +++ b/src/lib/export/filename.ts @@ -0,0 +1,40 @@ +// Export filename convention: {vendor-slug}_{invoiceNumber}_{issueDate}.{ext} +// with an Arabic-safe slug fallback and -2/-3 collision suffixes. + +export function slugify(input: string): string { + const ascii = input + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') // strip combining marks + .toLowerCase(); + return ascii.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} + +export interface FilenameParts { + vendorName: string | null; + invoiceNumber: string | null; + issueDate: string | null; +} + +/** Build the base filename (no extension) from invoice parts. */ +export function baseFilename(parts: FilenameParts): string { + const vendor = slugify(parts.vendorName ?? '') || 'vendor'; + const invoice = slugify(parts.invoiceNumber ?? '') || 'invoice'; + const date = + parts.issueDate && /^\d{4}-\d{2}-\d{2}$/.test(parts.issueDate) + ? parts.issueDate + : slugify(parts.issueDate ?? '') || 'undated'; + return `${vendor}_${invoice}_${date}`; +} + +/** Build a filename with extension, suffixing -2/-3… on collision. */ +export function uniqueFilename(parts: FilenameParts, ext: string, taken: Set): string { + const base = baseFilename(parts); + let candidate = `${base}.${ext}`; + let counter = 2; + while (taken.has(candidate)) { + candidate = `${base}-${String(counter)}.${ext}`; + counter += 1; + } + taken.add(candidate); + return candidate; +} diff --git a/src/lib/export/index.ts b/src/lib/export/index.ts new file mode 100644 index 0000000..28403ef --- /dev/null +++ b/src/lib/export/index.ts @@ -0,0 +1,5 @@ +export * from './json.ts'; +export * from './csv.ts'; +export * from './filename.ts'; +export * from './clipboard.ts'; +export * from './batch.ts'; diff --git a/src/lib/export/json.test.ts b/src/lib/export/json.test.ts new file mode 100644 index 0000000..39065ea --- /dev/null +++ b/src/lib/export/json.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { invoiceV1Schema } from '../../schema/invoice.ts'; +import { makeInput } from './export-fixtures.ts'; +import { buildEnvelope, canonicalStringify, exportInvoiceJson } from './json.ts'; + +describe('canonicalStringify', () => { + it('orders object keys deterministically regardless of input order', () => { + const a = canonicalStringify({ b: 1, a: 2, c: { z: 1, y: 2 } }); + const b = canonicalStringify({ c: { y: 2, z: 1 }, a: 2, b: 1 }); + expect(a).toBe(b); + }); + + it('preserves array order', () => { + expect(canonicalStringify([3, 1, 2])).toBe('[\n 3,\n 1,\n 2\n]'); + }); +}); + +describe('exportInvoiceJson', () => { + it('produces a stable string for the same input', () => { + const input = makeInput(); + expect(exportInvoiceJson(input)).toBe(exportInvoiceJson(input)); + }); + + it('round-trips the invoice back through the schema', () => { + const envelope = buildEnvelope(makeInput()); + const parsed = JSON.parse(exportInvoiceJson(makeInput())) as { invoice: unknown }; + expect(invoiceV1Schema.safeParse(parsed.invoice).success).toBe(true); + expect(envelope.faturlens.model).toBe('LFM2.5-VL-1.6B-ONNX'); + }); + + it('marks unapproved exports as needing review', () => { + const envelope = buildEnvelope(makeInput({ approved: false })); + expect(envelope.faturlens.needsReview).toBe(true); + }); +}); diff --git a/src/lib/export/json.ts b/src/lib/export/json.ts new file mode 100644 index 0000000..fb5e701 --- /dev/null +++ b/src/lib/export/json.ts @@ -0,0 +1,71 @@ +// Canonical JSON export with a provenance envelope and a deterministic, +// key-ordered serializer so diffs of exports are meaningful. + +import type { InvoiceV1 } from '../../schema/invoice.ts'; +import type { Finding, ReviewVerdict } from '../../schema/rules/index.ts'; +import type { EditEntry } from '../../ui/review/state.ts'; + +export const FATURLENS_EXPORT_VERSION = '1.0'; +export const MODEL_ID = 'LFM2.5-VL-1.6B-ONNX'; + +export interface ExportInput { + invoice: InvoiceV1; + verdict: ReviewVerdict; + findings: Finding[]; + promptVersions: { pass1: string; pass2: string }; + edits: EditEntry[]; + approved: boolean; + /** ISO timestamp; supplied by the caller to keep exports deterministic. */ + exportedAt: string; +} + +export interface ExportEnvelope { + faturlens: { + version: string; + exportedAt: string; + model: string; + promptVersions: { pass1: string; pass2: string }; + validation: { verdict: ReviewVerdict; findings: Finding[] }; + edits: EditEntry[]; + approved: boolean; + needsReview: boolean; + }; + invoice: InvoiceV1; +} + +export function buildEnvelope(input: ExportInput): ExportEnvelope { + return { + faturlens: { + version: FATURLENS_EXPORT_VERSION, + exportedAt: input.exportedAt, + model: MODEL_ID, + promptVersions: input.promptVersions, + validation: { verdict: input.verdict, findings: input.findings }, + edits: input.edits, + approved: input.approved, + needsReview: !input.approved, + }, + invoice: input.invoice, + }; +} + +/** Deterministic JSON: object keys sorted recursively, arrays preserved. */ +export function canonicalStringify(value: unknown, indent = 2): string { + return JSON.stringify(sortKeys(value), null, indent); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value as Record).sort(([a], [b]) => + a < b ? -1 : a > b ? 1 : 0, + ); + return Object.fromEntries(entries.map(([k, v]) => [k, sortKeys(v)])); + } + return value; +} + +/** Serialize one invoice export to a canonical JSON string. */ +export function exportInvoiceJson(input: ExportInput): string { + return canonicalStringify(buildEnvelope(input)); +} diff --git a/src/lib/hash.ts b/src/lib/hash.ts new file mode 100644 index 0000000..af8d62b --- /dev/null +++ b/src/lib/hash.ts @@ -0,0 +1,10 @@ +// sha256 of bytes, hex-encoded. Uses Web Crypto (available in browsers and Node). + +export async function sha256Hex(bytes: ArrayBuffer | Uint8Array): Promise { + const buffer: ArrayBuffer = + bytes instanceof Uint8Array + ? (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer) + : bytes; + const digest = await crypto.subtle.digest('SHA-256', buffer); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..5642c75 --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,8 @@ +// Version surface shown in the footer and stamped into exports. + +export const APP_VERSION = '1.0.0'; +export const MODEL_ID = 'LFM2.5-VL-1.6B-ONNX'; +export const PROMPT_VERSIONS = { + pass1: 'PASS1_PROMPT_V1', + pass2: 'PASS2_PROMPT_V1', +} as const; diff --git a/src/main.tsx b/src/main.tsx index 772bd60..32a0655 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,8 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App.tsx'; import { DeviceProfileProvider } from './capability/useDeviceProfile.tsx'; +import { ErrorBoundary } from './ui/ErrorBoundary.tsx'; +import './ui/tokens.css'; import './index.css'; const rootElement = document.getElementById('root'); @@ -11,8 +13,10 @@ if (!rootElement) { createRoot(rootElement).render( - - - + + + + + , ); diff --git a/src/perf/README.md b/src/perf/README.md new file mode 100644 index 0000000..5942ce2 --- /dev/null +++ b/src/perf/README.md @@ -0,0 +1,30 @@ +# perf + +Performance hardening: honest estimates, recovery, and tuning. The decision +logic is pure and unit-tested; the worker/UI apply it. + +## Modules + +- `estimate.ts` — `benchmarkToTps()` turns a startup warmup (tokens/ms) into + throughput; `estimatePageSeconds()` / `estimateBatchSeconds()` / + `formatEstimate()` give upfront per-page and per-batch estimates shown before + processing starts. +- `recovery.ts` — the WebGPU device-lost ladder: first loss recreates sessions; + a **second loss within 10 minutes demotes to WASM**. The worker calls + `onDeviceLost()` when it catches a device-lost error. +- `autotune.ts` — `suggestBudget()` proposes a smaller image token budget when + WebGPU decode drops below 3 tok/s (user-confirmed before it is stored). + +## Other hardening + +- **WASM path** runs the full pipeline; the profile forces the WASM EP and the + UI shows realistic minutes-per-page from the benchmark. +- **Runtime tile downscale**: `planTiles` already clamps to the device budget + (Phase 05); over-budget pages are downscaled and noted on the page record. +- **Off-main-thread**: blob/canvas/tensor work runs in the workers, keeping + main-thread tasks short during processing. +- **Resumable on tab close**: `store/resumable.ts` resets any `processing` + document to `queued` on `beforeunload`, so an abrupt close never corrupts the + queue. +- **Local-only telemetry**: per-stage timings render in ``; + nothing leaves the machine. diff --git a/src/perf/autotune.test.ts b/src/perf/autotune.test.ts new file mode 100644 index 0000000..520b217 --- /dev/null +++ b/src/perf/autotune.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { suggestBudget, TOKEN_BUDGET_FULL, TOKEN_BUDGET_REDUCED } from './autotune.ts'; + +describe('suggestBudget', () => { + it('suggests reducing when WebGPU decode is slow and budget is full', () => { + const s = suggestBudget('webgpu', 2, TOKEN_BUDGET_FULL); + expect(s.suggest).toBe(true); + expect(s.proposed).toBe(TOKEN_BUDGET_REDUCED); + }); + + it('does not suggest when throughput is acceptable', () => { + expect(suggestBudget('webgpu', 5, TOKEN_BUDGET_FULL).suggest).toBe(false); + }); + + it('does not suggest on WASM', () => { + expect(suggestBudget('wasm', 1, TOKEN_BUDGET_FULL).suggest).toBe(false); + }); + + it('does not suggest when already at the reduced floor', () => { + expect(suggestBudget('webgpu', 1, TOKEN_BUDGET_REDUCED).suggest).toBe(false); + }); +}); diff --git a/src/perf/autotune.ts b/src/perf/autotune.ts new file mode 100644 index 0000000..71a95d2 --- /dev/null +++ b/src/perf/autotune.ts @@ -0,0 +1,48 @@ +// Token-budget auto-tune. Pure decisions; the user confirms before any change +// is stored in settings. + +import { + TOKEN_BUDGET_FULL, + TOKEN_BUDGET_REDUCED, + type ExecutionProvider, +} from '../capability/profile.ts'; + +/** Below this decode throughput on WebGPU, suggest a smaller image token budget. */ +export const MIN_ACCEPTABLE_TPS = 3; + +export interface BudgetSuggestion { + suggest: boolean; + current: number; + proposed: number; + reason: string; +} + +/** + * Suggest reducing the image token budget when WebGPU decode throughput is too + * low and the budget is not already at the floor. + */ +export function suggestBudget( + provider: ExecutionProvider, + tokensPerSecond: number, + currentBudget: number, +): BudgetSuggestion { + const noChange: BudgetSuggestion = { + suggest: false, + current: currentBudget, + proposed: currentBudget, + reason: '', + }; + + if (provider !== 'webgpu') return noChange; + if (tokensPerSecond >= MIN_ACCEPTABLE_TPS) return noChange; + if (currentBudget <= TOKEN_BUDGET_REDUCED) return noChange; + + return { + suggest: true, + current: currentBudget, + proposed: TOKEN_BUDGET_REDUCED, + reason: `Decode is ${tokensPerSecond.toFixed(1)} tok/s (< ${String(MIN_ACCEPTABLE_TPS)}); a smaller image budget will speed things up`, + }; +} + +export { TOKEN_BUDGET_FULL, TOKEN_BUDGET_REDUCED }; diff --git a/src/perf/estimate.test.ts b/src/perf/estimate.test.ts new file mode 100644 index 0000000..57f2aa6 --- /dev/null +++ b/src/perf/estimate.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { + benchmarkToTps, + estimateBatchSeconds, + estimatePageSeconds, + formatEstimate, +} from './estimate.ts'; + +describe('benchmarkToTps', () => { + it('converts tokens over ms to tokens/second', () => { + expect(benchmarkToTps(64, 8000)).toBe(8); + }); + it('returns 0 for non-positive elapsed', () => { + expect(benchmarkToTps(64, 0)).toBe(0); + }); +}); + +describe('estimatePageSeconds', () => { + it('divides expected tokens by throughput', () => { + expect(estimatePageSeconds(12, 1200)).toBe(100); + }); + it('is Infinity at zero throughput', () => { + expect(estimatePageSeconds(0)).toBe(Infinity); + }); +}); + +describe('estimateBatchSeconds', () => { + it('scales by page count', () => { + expect(estimateBatchSeconds(3, 12)).toBe(300); + }); +}); + +describe('formatEstimate', () => { + it('shows seconds under 90s', () => { + expect(formatEstimate(45)).toBe('~45s'); + }); + it('shows minutes for longer estimates', () => { + expect(formatEstimate(180)).toBe('~3 min'); + }); + it('shows unknown for Infinity', () => { + expect(formatEstimate(Infinity)).toBe('unknown'); + }); +}); diff --git a/src/perf/estimate.ts b/src/perf/estimate.ts new file mode 100644 index 0000000..8398c8a --- /dev/null +++ b/src/perf/estimate.ts @@ -0,0 +1,31 @@ +// Throughput benchmark → per-page time estimates. Pure math, unit-tested. + +/** Expected generated tokens for a two-pass page (transcription + extraction). */ +export const EXPECTED_TOKENS_PER_PAGE = 1200; + +/** Convert a warmup run (tokens over milliseconds) into tokens/second. */ +export function benchmarkToTps(tokens: number, elapsedMs: number): number { + if (elapsedMs <= 0) return 0; + return tokens / (elapsedMs / 1000); +} + +/** Seconds to process one page at a given throughput. */ +export function estimatePageSeconds( + tokensPerSecond: number, + expectedTokens = EXPECTED_TOKENS_PER_PAGE, +): number { + if (tokensPerSecond <= 0) return Infinity; + return expectedTokens / tokensPerSecond; +} + +/** Human-friendly estimate string for the UI. */ +export function formatEstimate(seconds: number): string { + if (!Number.isFinite(seconds)) return 'unknown'; + if (seconds < 90) return `~${String(Math.round(seconds))}s`; + return `~${String(Math.round(seconds / 60))} min`; +} + +/** Total seconds for a batch of pages. */ +export function estimateBatchSeconds(pageCount: number, tokensPerSecond: number): number { + return pageCount * estimatePageSeconds(tokensPerSecond); +} diff --git a/src/perf/recovery.test.ts b/src/perf/recovery.test.ts new file mode 100644 index 0000000..2c2a771 --- /dev/null +++ b/src/perf/recovery.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { initialRecoveryState, onDeviceLost } from './recovery.ts'; + +describe('onDeviceLost', () => { + it('recreates WebGPU on the first loss', () => { + const { state, decision } = onDeviceLost(initialRecoveryState('webgpu'), 1000); + expect(decision).toBe('recreate-webgpu'); + expect(state.provider).toBe('webgpu'); + }); + + it('demotes to WASM on a second loss within the window', () => { + let state = initialRecoveryState('webgpu'); + ({ state } = onDeviceLost(state, 1000)); + const second = onDeviceLost(state, 1000 + 60_000); // 1 min later + expect(second.decision).toBe('demote-to-wasm'); + expect(second.state.provider).toBe('wasm'); + }); + + it('recreates WebGPU when the second loss is outside the window', () => { + let state = initialRecoveryState('webgpu'); + ({ state } = onDeviceLost(state, 1000)); + const later = onDeviceLost(state, 1000 + 11 * 60_000); // 11 min later + expect(later.decision).toBe('recreate-webgpu'); + expect(later.state.provider).toBe('webgpu'); + }); + + it('just recreates WASM sessions when already on WASM', () => { + const { decision } = onDeviceLost(initialRecoveryState('wasm'), 1000); + expect(decision).toBe('recreate-wasm'); + }); +}); diff --git a/src/perf/recovery.ts b/src/perf/recovery.ts new file mode 100644 index 0000000..eb16b48 --- /dev/null +++ b/src/perf/recovery.ts @@ -0,0 +1,53 @@ +// WebGPU device-lost recovery ladder. Pure state machine: the first loss +// recreates sessions; a second loss within the window demotes to WASM. + +import type { ExecutionProvider } from '../capability/profile.ts'; + +export const REPEATED_LOSS_WINDOW_MS = 10 * 60 * 1000; // 10 minutes + +export interface RecoveryState { + provider: ExecutionProvider; + /** Timestamps of recent device-loss events. */ + lossTimestamps: number[]; +} + +export type RecoveryDecision = 'recreate-webgpu' | 'demote-to-wasm' | 'recreate-wasm'; + +export interface RecoveryOutcome { + state: RecoveryState; + decision: RecoveryDecision; +} + +export function initialRecoveryState(provider: ExecutionProvider): RecoveryState { + return { provider, lossTimestamps: [] }; +} + +/** + * Decide how to recover from a device-lost event at time `now`. + * - Already on WASM → just recreate WASM sessions. + * - First WebGPU loss (in window) → recreate WebGPU. + * - Second WebGPU loss within the window → demote to WASM. + */ +export function onDeviceLost( + state: RecoveryState, + now: number, + windowMs = REPEATED_LOSS_WINDOW_MS, +): RecoveryOutcome { + if (state.provider === 'wasm') { + return { state, decision: 'recreate-wasm' }; + } + + const recent = state.lossTimestamps.filter((t) => now - t <= windowMs); + recent.push(now); + + if (recent.length >= 2) { + return { + state: { provider: 'wasm', lossTimestamps: recent }, + decision: 'demote-to-wasm', + }; + } + return { + state: { provider: 'webgpu', lossTimestamps: recent }, + decision: 'recreate-webgpu', + }; +} diff --git a/src/pipeline/pass2/README.md b/src/pipeline/pass2/README.md new file mode 100644 index 0000000..822451c --- /dev/null +++ b/src/pipeline/pass2/README.md @@ -0,0 +1,15 @@ +# pipeline/pass2 + +Pass 2 — schema-constrained JSON extraction. + +- `prompt.ts` — `PASS2_PROMPT_V1` (image(s) + Pass 1 transcript → JSON only) and + `correctivePrompt()` for the single re-prompt. +- `repair.ts` — deterministic JSON repair ladder: strip fences → extract the + outermost balanced `{}` → normalize syntax (trailing commas, single quotes, + unquoted keys) → parse, with double-encoded-string handling. Pure, per-step. +- `run.ts` — `runPass2()`: infer → repair → validate against the raw contract → + `mapRawToInvoice`. On a parse/validation failure, exactly **one** corrective + re-prompt that echoes the errors, then a typed terminal failure. + +See `src/schema/` for the `InvoiceV1` schema, the raw model JSON contract, and +the field-confidence semantics. diff --git a/src/pipeline/pass2/prompt.ts b/src/pipeline/pass2/prompt.ts new file mode 100644 index 0000000..5aa2a83 --- /dev/null +++ b/src/pipeline/pass2/prompt.ts @@ -0,0 +1,34 @@ +// Pass 2 prompt: schema-constrained JSON extraction. Versioned artifact. + +export const PASS2_PROMPT_VERSION = 'PASS2_PROMPT_V1'; + +export const PASS2_PROMPT_V1 = `You extract structured data from an invoice. You are given the invoice image(s) and a Markdown transcript. + +Output ONLY a single JSON object — no prose, no code fences — matching this shape: + +{ + "vendor": { "name": string|null, "address": string|null, "trn": string|null, "phone": string|null, "email": string|null }, + "buyer": { "name": string|null, "address": string|null, "trn": string|null, "phone": string|null, "email": string|null } | null, + "invoiceNumber": string|null, + "issueDate": "YYYY-MM-DD"|null, + "dueDate": "YYYY-MM-DD"|null, + "currency": string|null, // ISO 4217, e.g. "AED" + "lineItems": [ { "description": string|null, "quantity": number|null, "unitPrice": number|null, "amount": number|null, "taxRate": number|null } ], + "subtotal": number|null, + "taxAmount": number|null, + "total": number|null, + "paymentTerms": string|null, + "notes": string|null, + "_uncertain": [ string ] // field paths you are unsure about, e.g. "subtotal" +} + +Rules: +- Use null for any field not present. NEVER invent a value. +- Copy numbers EXACTLY as printed (no rounding); output them as JSON numbers. +- Normalize dates to YYYY-MM-DD. +- List the path of any value you are uncertain about in "_uncertain".`; + +/** Build a corrective re-prompt that appends the validator's complaint. */ +export function correctivePrompt(basePrompt: string, errors: string): string { + return `${basePrompt}\n\n## Your previous output was invalid\n${errors}\n\nReturn corrected JSON only.`; +} diff --git a/src/pipeline/pass2/repair.test.ts b/src/pipeline/pass2/repair.test.ts new file mode 100644 index 0000000..b6fda78 --- /dev/null +++ b/src/pipeline/pass2/repair.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { extractBalancedObject, normalizeSyntax, repairJson, stripFences } from './repair.ts'; + +describe('stripFences', () => { + it('removes json code fences', () => { + expect(stripFences('```json\n{"a":1}\n```')).toBe('{"a":1}'); + }); +}); + +describe('extractBalancedObject', () => { + it('extracts the outermost object ignoring braces in strings', () => { + expect(extractBalancedObject('noise {"a":"}{"} tail')).toBe('{"a":"}{"}'); + }); + it('returns null for truncated input', () => { + expect(extractBalancedObject('{"a": 1, "b":')).toBeNull(); + }); +}); + +describe('normalizeSyntax', () => { + it('removes trailing commas', () => { + expect(normalizeSyntax('{"a":1,}')).toBe('{"a":1}'); + }); + it('quotes unquoted keys', () => { + expect(normalizeSyntax('{a:1}')).toContain('"a":1'); + }); + it('converts single quotes', () => { + expect(normalizeSyntax("{'a':'b'}")).toContain('"a":"b"'); + }); +}); + +describe('repairJson — malformed fixtures', () => { + const valid = { invoiceNumber: 'INV-1', total: 100 }; + + it('parses clean JSON', () => { + expect(repairJson('{"invoiceNumber":"INV-1","total":100}')).toEqual({ ok: true, value: valid }); + }); + + it('parses fenced JSON', () => { + expect(repairJson('```json\n{"invoiceNumber":"INV-1","total":100}\n```').ok).toBe(true); + }); + + it('parses prose-wrapped JSON', () => { + const r = repairJson('Here is the result:\n{"invoiceNumber":"INV-1","total":100}\nThanks!'); + expect(r.ok).toBe(true); + }); + + it('repairs trailing commas', () => { + expect(repairJson('{"invoiceNumber":"INV-1","total":100,}').ok).toBe(true); + }); + + it('repairs single quotes', () => { + expect(repairJson("{'invoiceNumber':'INV-1','total':100}").ok).toBe(true); + }); + + it('repairs unquoted keys', () => { + expect(repairJson('{invoiceNumber:"INV-1",total:100}').ok).toBe(true); + }); + + it('handles double-encoded JSON strings', () => { + const doubled = JSON.stringify(JSON.stringify(valid)); + const r = repairJson(doubled); + expect(r).toEqual({ ok: true, value: valid }); + }); + + it('fails with extract-braces stage when no object present', () => { + const r = repairJson('totally not json'); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.stage).toBe('extract-braces'); + expect(typeof r.message).toBe('string'); + } + }); + + it('fails with extract-braces stage on truncated output', () => { + const r = repairJson('{"invoiceNumber":"INV-1", "total":'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.stage).toBe('extract-braces'); + }); + + it('fails with parse stage on irreparable syntax', () => { + const r = repairJson('{"a": @@@ }'); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.stage).toBe('parse'); + }); + + it('repairs combined trailing comma + unquoted key', () => { + expect(repairJson('{invoiceNumber:"INV-1", total:100,}').ok).toBe(true); + }); +}); diff --git a/src/pipeline/pass2/repair.ts b/src/pipeline/pass2/repair.ts new file mode 100644 index 0000000..1a8fc94 --- /dev/null +++ b/src/pipeline/pass2/repair.ts @@ -0,0 +1,90 @@ +// Deterministic JSON repair ladder. Each step is pure and independently tested. +// On failure, returns the furthest stage reached so callers can reason about it. + +export type RepairStage = 'extract-braces' | 'parse'; + +export type RepairResult = + | { ok: true; value: unknown } + | { ok: false; stage: RepairStage; message: string }; + +/** Step a: strip Markdown code fences and surrounding prose markers. */ +export function stripFences(text: string): string { + return text + .replace(/```[a-zA-Z]*\n?/g, '') + .replace(/```/g, '') + .trim(); +} + +/** Step b: extract the outermost balanced `{ … }` block, ignoring string contents. */ +export function extractBalancedObject(text: string): string | null { + const start = text.indexOf('{'); + if (start === -1) return null; + + let depth = 0; + let inString = false; + let escaped = false; + + for (let i = start; i < text.length; i += 1) { + const ch = text[i]; + if (inString) { + if (escaped) escaped = false; + else if (ch === '\\') escaped = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') inString = true; + else if (ch === '{') depth += 1; + else if (ch === '}') { + depth -= 1; + if (depth === 0) return text.slice(start, i + 1); + } + } + return null; // truncated / unbalanced +} + +/** Step c: fix trailing commas, single quotes, and unquoted keys. */ +export function normalizeSyntax(json: string): string { + return json + .replace(/,(\s*[}\]])/g, '$1') // trailing commas + .replace(/'((?:[^'\\]|\\.)*)'/g, '"$1"') // single-quoted strings → double + .replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)/g, '$1"$2"$3'); // unquoted keys +} + +function tryParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +/** Run the full ladder: strip → extract → (parse | normalize → parse). */ +export function repairJson(text: string): RepairResult { + const stripped = stripFences(text); + + // Double-encoded: the whole payload is a JSON string that contains JSON. + const asString = tryParse(stripped); + if (typeof asString === 'string' && asString.trim().startsWith('{')) { + const inner = repairJson(asString); + if (inner.ok) return inner; + } + + const block = extractBalancedObject(stripped); + if (block === null) { + return { ok: false, stage: 'extract-braces', message: 'No balanced JSON object found' }; + } + + let parsed = tryParse(block); + if (parsed === undefined) parsed = tryParse(normalizeSyntax(block)); + + // Handle a double-encoded payload: a JSON string that itself contains JSON. + if (typeof parsed === 'string' && parsed.trim().startsWith('{')) { + const inner = repairJson(parsed); + if (inner.ok) return inner; + } + + if (parsed === undefined) { + return { ok: false, stage: 'parse', message: 'JSON.parse failed after normalization' }; + } + return { ok: true, value: parsed }; +} diff --git a/src/pipeline/pass2/run.test.ts b/src/pipeline/pass2/run.test.ts new file mode 100644 index 0000000..e462ada --- /dev/null +++ b/src/pipeline/pass2/run.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import type { GenerationStats } from '../../worker/protocol.ts'; +import { runPass2, type Pass2Client } from './run.ts'; + +const stats: GenerationStats = { + prefillMs: 1, + decodeMs: 1, + tokensPerSecond: 1, + peakMemoryEstimateMB: 1, +}; + +function handleOf(fullText: string) { + return { + completed: Promise.resolve({ fullText, stats }), + [Symbol.asyncIterator](): AsyncIterator { + let done = false; + return { + next: (): Promise> => { + if (done) return Promise.resolve({ value: undefined, done: true }); + done = true; + return Promise.resolve({ value: fullText, done: false }); + }, + }; + }, + }; +} + +function scriptedClient(texts: string[]): Pass2Client & { prompts: string[] } { + const prompts: string[] = []; + let index = 0; + return { + prompts, + infer: (req) => { + prompts.push(req.prompt); + const text = texts[index] ?? ''; + index += 1; + return handleOf(text); + }, + }; +} + +const validJson = + '{"vendor":{"name":"V"},"invoiceNumber":"1","issueDate":"2026-01-01","currency":"AED","lineItems":[],"subtotal":0,"taxAmount":0,"total":0}'; + +describe('runPass2', () => { + it('extracts on the first valid response', async () => { + const client = scriptedClient([validJson]); + const result = await runPass2({ images: [], transcript: 't' }, client); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.retried).toBe(false); + expect(result.invoice.vendor.name.value).toBe('V'); + expect(result.promptVersion).toBe('PASS2_PROMPT_V1'); + } + expect(client.prompts).toHaveLength(1); + }); + + it('issues one corrective re-prompt after an unparseable response', async () => { + const client = scriptedClient(['not json at all', validJson]); + const result = await runPass2({ images: [], transcript: 't' }, client); + expect(result.ok).toBe(true); + expect(client.prompts).toHaveLength(2); + expect(client.prompts[1]).toContain('previous output was invalid'); + }); + + it('re-prompts on a schema validation failure', async () => { + const client = scriptedClient(['{"lineItems": "not-an-array"}', validJson]); + const result = await runPass2({ images: [], transcript: 't' }, client); + expect(result.ok).toBe(true); + expect(client.prompts).toHaveLength(2); + }); + + it('gives up with a typed repair failure after two bad responses', async () => { + const client = scriptedClient(['garbage', 'still garbage']); + const result = await runPass2({ images: [], transcript: 't' }, client); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.stage).toBe('repair'); + expect(result.retried).toBe(true); + } + expect(client.prompts).toHaveLength(2); + }); + + it('gives up with a typed validation failure when both responses fail the schema', async () => { + const client = scriptedClient(['{"lineItems": 5}', '{"lineItems": 7}']); + const result = await runPass2({ images: [], transcript: 't' }, client); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.stage).toBe('validation'); + }); +}); diff --git a/src/pipeline/pass2/run.ts b/src/pipeline/pass2/run.ts new file mode 100644 index 0000000..2998bcd --- /dev/null +++ b/src/pipeline/pass2/run.ts @@ -0,0 +1,106 @@ +// Pass 2 runner: build the prompt, infer, repair JSON, validate against the raw +// contract, and map to InvoiceV1. On a parse/validation failure, one corrective +// re-prompt that echoes the errors, then a typed terminal failure. + +import { type z } from 'zod'; +import type { InvoiceV1 } from '../../schema/invoice.ts'; +import { mapRawToInvoice, rawInvoiceSchema, type RawInvoice } from '../../schema/json-types.ts'; +import type { GenerationStats } from '../../worker/protocol.ts'; +import { correctivePrompt, PASS2_PROMPT_V1, PASS2_PROMPT_VERSION } from './prompt.ts'; +import { repairJson } from './repair.ts'; + +interface InferHandleLike extends AsyncIterable { + completed: Promise<{ fullText: string; stats: GenerationStats }>; +} + +export interface Pass2Client { + infer: (request: { + images: ImageBitmap[]; + prompt: string; + maxNewTokens?: number; + }) => InferHandleLike; +} + +export interface Pass2Input { + images: ImageBitmap[]; + transcript: string; +} + +export type Pass2Result = + | { + ok: true; + invoice: InvoiceV1; + raw: RawInvoice; + promptVersion: string; + retried: boolean; + } + | { ok: false; stage: 'repair' | 'validation'; message: string; retried: boolean }; + +export interface Pass2Options { + maxNewTokens?: number; +} + +async function infer(handle: InferHandleLike): Promise { + // Drain the stream; Pass 2 only needs the full text. + for await (const token of handle) void token; + return (await handle.completed).fullText; +} + +function formatZodError(error: z.ZodError): string { + return error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('\n'); +} + +/** Run Pass 2 extraction with a single corrective re-prompt on failure. */ +export async function runPass2( + input: Pass2Input, + client: Pass2Client, + options: Pass2Options = {}, +): Promise { + const maxNewTokens = options.maxNewTokens ?? 2048; + const basePrompt = `${PASS2_PROMPT_V1}\n\n## Transcript\n${input.transcript}`; + + let prompt = basePrompt; + let retried = false; + + for (let attempt = 0; attempt < 2; attempt += 1) { + const text = await infer(client.infer({ images: input.images, prompt, maxNewTokens })); + + const repaired = repairJson(text); + if (!repaired.ok) { + if (attempt === 0) { + retried = true; + prompt = correctivePrompt(basePrompt, `Could not parse JSON: ${repaired.message}`); + continue; + } + return { ok: false, stage: 'repair', message: repaired.message, retried }; + } + + const parsed = rawInvoiceSchema.safeParse(repaired.value); + if (!parsed.success) { + const message = formatZodError(parsed.error); + if (attempt === 0) { + retried = true; + prompt = correctivePrompt(basePrompt, message); + continue; + } + return { ok: false, stage: 'validation', message, retried }; + } + + return { + ok: true, + invoice: mapRawToInvoice(parsed.data), + raw: parsed.data, + promptVersion: PASS2_PROMPT_VERSION, + retried, + }; + } + + return { + ok: false, + stage: 'validation', + message: 'Extraction attempts exhausted', + retried: true, + }; +} diff --git a/src/schema/README.md b/src/schema/README.md index 287659d..be4c428 100644 --- a/src/schema/README.md +++ b/src/schema/README.md @@ -1,10 +1,24 @@ # schema -Zod schemas and the deterministic validation layer. +Zod schemas, the raw model JSON contract, and (Phase 08) the validation layer. -- `invoice.ts` — the `InvoiceV1` schema with per-field confidence wrappers. -- `json-types.ts` — the raw model JSON contract plus the mapper to `InvoiceV1`. -- `rules/` — pure-TypeScript, zero-ML validation rules. This layer is the trust - boundary: it gates every extraction result and never silently fixes values. +## Modules -_Populated in Phases 07–08._ +- `invoice.ts` — `InvoiceV1`, the validated invoice schema. Every leaf is a + `Field = { value: T | null; confidence }`. +- `json-types.ts` — `rawInvoiceSchema` (the flat, lenient JSON the model emits) + plus `mapRawToInvoice()` and `parseNumber()`. +- `rules/` — deterministic validation (Phase 08). + +## Field confidence semantics + +| confidence | meaning | +| ----------- | ------------------------------------------------------ | +| `extracted` | read directly from the document | +| `inferred` | present but the model flagged the path in `_uncertain` | +| `missing` | absent — `value` is `null` | + +`parseNumber` tolerates formatted strings ("AED 1,234.50" → `1234.5`) and +returns `null` for anything non-numeric, so a garbled figure surfaces as a +missing value rather than a wrong one. The `uncertain` path list rides along on +the invoice for the validation layer (rule R024) and the export envelope. diff --git a/src/schema/invoice.ts b/src/schema/invoice.ts new file mode 100644 index 0000000..76cf623 --- /dev/null +++ b/src/schema/invoice.ts @@ -0,0 +1,62 @@ +// InvoiceV1 — the validated, confidence-annotated invoice schema. Every leaf is +// wrapped as a Field so the UI and validation layer can track, per value, +// whether it was directly extracted, inferred, or missing. + +import { z } from 'zod'; + +export const confidenceSchema = z.enum(['extracted', 'inferred', 'missing']); +export type Confidence = z.infer; + +export interface Field { + value: T | null; + confidence: Confidence; +} + +function field( + inner: T, +): z.ZodObject<{ + value: z.ZodNullable; + confidence: typeof confidenceSchema; +}> { + return z.object({ value: inner.nullable(), confidence: confidenceSchema }); +} + +const stringField = field(z.string()); +const numberField = field(z.number()); + +export const partySchema = z.object({ + name: stringField, + address: stringField, + trn: stringField, + phone: stringField, + email: stringField, +}); + +export const lineItemSchema = z.object({ + description: stringField, + quantity: numberField, + unitPrice: numberField, + amount: numberField, + taxRate: numberField.optional(), +}); + +export const invoiceV1Schema = z.object({ + vendor: partySchema, + buyer: partySchema.optional(), + invoiceNumber: stringField, + issueDate: stringField, + dueDate: stringField.optional(), + currency: stringField, + lineItems: z.array(lineItemSchema), + subtotal: numberField, + taxAmount: numberField, + total: numberField, + paymentTerms: stringField.optional(), + notes: stringField.optional(), + /** Field paths the model flagged as uncertain (e.g. "subtotal"). */ + uncertain: z.array(z.string()), +}); + +export type InvoiceV1 = z.infer; +export type Party = z.infer; +export type LineItem = z.infer; diff --git a/src/schema/json-types.test.ts b/src/schema/json-types.test.ts new file mode 100644 index 0000000..6b46794 --- /dev/null +++ b/src/schema/json-types.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { invoiceV1Schema } from './invoice.ts'; +import { mapRawToInvoice, parseNumber, rawInvoiceSchema, type RawInvoice } from './json-types.ts'; + +const complete: RawInvoice = { + vendor: { name: 'Acme FZE', address: 'Dubai', trn: '100123456700003', phone: null, email: null }, + buyer: { name: 'Buyer LLC', address: null, trn: null, phone: null, email: null }, + invoiceNumber: 'INV-001', + issueDate: '2026-01-15', + dueDate: '2026-02-15', + currency: 'AED', + lineItems: [{ description: 'Widget', quantity: 2, unitPrice: '50.00', amount: 100, taxRate: 5 }], + subtotal: 100, + taxAmount: 5, + total: 105, + paymentTerms: 'Net 30', + notes: null, + _uncertain: ['subtotal'], +}; + +describe('parseNumber', () => { + it('passes through finite numbers', () => { + expect(parseNumber(42)).toBe(42); + }); + it('strips currency and separators from strings', () => { + expect(parseNumber('AED 1,234.50')).toBe(1234.5); + }); + it('returns null for null/undefined/garbage', () => { + expect(parseNumber(null)).toBeNull(); + expect(parseNumber(undefined)).toBeNull(); + expect(parseNumber('abc')).toBeNull(); + }); +}); + +describe('mapRawToInvoice', () => { + it('maps a complete invoice and round-trips through the schema', () => { + const invoice = mapRawToInvoice(complete); + expect(invoiceV1Schema.safeParse(invoice).success).toBe(true); + expect(invoice.vendor.name).toEqual({ value: 'Acme FZE', confidence: 'extracted' }); + expect(invoice.subtotal.value).toBe(100); + expect(invoice.lineItems[0]?.unitPrice.value).toBe(50); + }); + + it('marks uncertain paths as inferred', () => { + const invoice = mapRawToInvoice(complete); + expect(invoice.subtotal.confidence).toBe('inferred'); + expect(invoice.uncertain).toContain('subtotal'); + }); + + it('handles a sparse invoice with mostly nulls', () => { + const sparse: RawInvoice = { + vendor: null, + buyer: null, + invoiceNumber: null, + issueDate: null, + dueDate: null, + currency: null, + lineItems: null, + subtotal: null, + taxAmount: null, + total: null, + paymentTerms: null, + notes: null, + _uncertain: null, + }; + const invoice = mapRawToInvoice(sparse); + expect(invoiceV1Schema.safeParse(invoice).success).toBe(true); + expect(invoice.vendor.name).toEqual({ value: null, confidence: 'missing' }); + expect(invoice.lineItems).toHaveLength(0); + expect(invoice.buyer).toBeUndefined(); + expect(invoice.dueDate).toBeUndefined(); + }); + + it('omits optional taxRate when absent', () => { + const raw = rawInvoiceSchema.parse({ + vendor: { name: 'V' }, + invoiceNumber: '1', + issueDate: '2026-01-01', + currency: 'AED', + lineItems: [{ description: 'X', quantity: 1, unitPrice: 1, amount: 1 }], + subtotal: 1, + taxAmount: 0, + total: 1, + }); + const invoice = mapRawToInvoice(raw); + expect(invoice.lineItems[0]?.taxRate).toBeUndefined(); + }); +}); diff --git a/src/schema/json-types.ts b/src/schema/json-types.ts new file mode 100644 index 0000000..d1974b7 --- /dev/null +++ b/src/schema/json-types.ts @@ -0,0 +1,144 @@ +// The raw, flat JSON contract the model is asked to emit (no Field wrappers), +// plus a mapper into the confidence-annotated InvoiceV1. + +import { z } from 'zod'; +import { + type Confidence, + type Field, + type InvoiceV1, + type LineItem, + type Party, +} from './invoice.ts'; + +const rawString = z.string().nullable().optional(); +// Numbers may arrive as numbers or numeric strings ("1,234.50"). +const rawNumber = z.union([z.number(), z.string()]).nullable().optional(); + +const rawPartySchema = z + .object({ + name: rawString, + address: rawString, + trn: rawString, + phone: rawString, + email: rawString, + }) + .nullable() + .optional(); + +const rawLineItemSchema = z.object({ + description: rawString, + quantity: rawNumber, + unitPrice: rawNumber, + amount: rawNumber, + taxRate: rawNumber, +}); + +export const rawInvoiceSchema = z.object({ + vendor: rawPartySchema, + buyer: rawPartySchema, + invoiceNumber: rawString, + issueDate: rawString, + dueDate: rawString, + currency: rawString, + lineItems: z.array(rawLineItemSchema).nullable().optional(), + subtotal: rawNumber, + taxAmount: rawNumber, + total: rawNumber, + paymentTerms: rawString, + notes: rawString, + _uncertain: z.array(z.string()).nullable().optional(), +}); + +export type RawInvoice = z.infer; + +/** Parse a numeric value that may be a number or a formatted string. */ +export function parseNumber(value: number | string | null | undefined): number | null { + if (value === null || value === undefined) return null; + if (typeof value === 'number') return Number.isFinite(value) ? value : null; + const cleaned = value.replace(/[^0-9.+-]/g, ''); + if (cleaned === '' || cleaned === '-' || cleaned === '+') return null; + const parsed = Number.parseFloat(cleaned); + return Number.isFinite(parsed) ? parsed : null; +} + +function confidenceFor(value: unknown, path: string, uncertain: Set): Confidence { + if (value === null || value === undefined) return 'missing'; + if (uncertain.has(path)) return 'inferred'; + return 'extracted'; +} + +function stringFieldFrom( + value: string | null | undefined, + path: string, + uncertain: Set, +): Field { + return { value: value ?? null, confidence: confidenceFor(value, path, uncertain) }; +} + +function numberFieldFrom( + value: number | string | null | undefined, + path: string, + uncertain: Set, +): Field { + const parsed = parseNumber(value); + return { value: parsed, confidence: confidenceFor(parsed, path, uncertain) }; +} + +function partyFrom( + raw: NonNullable | null | undefined, + prefix: string, + uncertain: Set, +): Party { + const p = raw ?? {}; + return { + name: stringFieldFrom(p.name, `${prefix}.name`, uncertain), + address: stringFieldFrom(p.address, `${prefix}.address`, uncertain), + trn: stringFieldFrom(p.trn, `${prefix}.trn`, uncertain), + phone: stringFieldFrom(p.phone, `${prefix}.phone`, uncertain), + email: stringFieldFrom(p.email, `${prefix}.email`, uncertain), + }; +} + +/** Map a validated raw model JSON object into the confidence-annotated InvoiceV1. */ +export function mapRawToInvoice(raw: RawInvoice): InvoiceV1 { + const uncertain = new Set(raw._uncertain ?? []); + + const lineItems: LineItem[] = (raw.lineItems ?? []).map((item, i) => { + const base = `lineItems.${String(i)}`; + const line: LineItem = { + description: stringFieldFrom(item.description, `${base}.description`, uncertain), + quantity: numberFieldFrom(item.quantity, `${base}.quantity`, uncertain), + unitPrice: numberFieldFrom(item.unitPrice, `${base}.unitPrice`, uncertain), + amount: numberFieldFrom(item.amount, `${base}.amount`, uncertain), + }; + if (item.taxRate !== null && item.taxRate !== undefined) { + line.taxRate = numberFieldFrom(item.taxRate, `${base}.taxRate`, uncertain); + } + return line; + }); + + const invoice: InvoiceV1 = { + vendor: partyFrom(raw.vendor, 'vendor', uncertain), + invoiceNumber: stringFieldFrom(raw.invoiceNumber, 'invoiceNumber', uncertain), + issueDate: stringFieldFrom(raw.issueDate, 'issueDate', uncertain), + currency: stringFieldFrom(raw.currency, 'currency', uncertain), + lineItems, + subtotal: numberFieldFrom(raw.subtotal, 'subtotal', uncertain), + taxAmount: numberFieldFrom(raw.taxAmount, 'taxAmount', uncertain), + total: numberFieldFrom(raw.total, 'total', uncertain), + uncertain: [...uncertain], + }; + + if (raw.buyer) invoice.buyer = partyFrom(raw.buyer, 'buyer', uncertain); + if (raw.dueDate !== null && raw.dueDate !== undefined) { + invoice.dueDate = stringFieldFrom(raw.dueDate, 'dueDate', uncertain); + } + if (raw.paymentTerms !== null && raw.paymentTerms !== undefined) { + invoice.paymentTerms = stringFieldFrom(raw.paymentTerms, 'paymentTerms', uncertain); + } + if (raw.notes !== null && raw.notes !== undefined) { + invoice.notes = stringFieldFrom(raw.notes, 'notes', uncertain); + } + + return invoice; +} diff --git a/src/schema/rules/README.md b/src/schema/rules/README.md new file mode 100644 index 0000000..133fa6c --- /dev/null +++ b/src/schema/rules/README.md @@ -0,0 +1,34 @@ +# schema/rules + +The deterministic validation layer — pure TypeScript, zero ML. This is the trust +boundary: it gates every extraction and **never auto-corrects** a value +(suggestions are display-only). + +## Engine + +`runRules(invoice, rules, ctx)` aggregates `Finding[]` and computes per-field +`ok | warning | error` status (error beats warning on the same path). +`validateInvoice()` runs `ALL_RULES` and folds the result into a `ReviewState` +(`clean | needs-review | rejected`). Money comparisons use integer minor units +with a one-minor-unit tolerance — never floating-point equality. + +## Rule catalog + +| ID | Severity | Field | Checks | +| ---- | --------------- | -------------------- | --------------------------------------------------- | +| R001 | error | `lineItems.N.amount` | quantity × unit price ≈ amount | +| R002 | error | `subtotal` | Σ line amounts ≈ subtotal | +| R003 | error | `total` | subtotal + tax ≈ total | +| R004 | warning | `taxAmount` | tax ≈ subtotal × dominant line rate | +| R010 | error / warning | `vendor.trn` | UAE TRN is 15 digits (malformed→error, absent→warn) | +| R011 | warning | `taxAmount` | AED invoices expect 5% VAT | +| R012 | warning / error | `currency` | preferred set ok; other ISO→warn; invalid→error | +| R020 | error | `dueDate` | issueDate ≤ dueDate | +| R021 | error | `issueDate` | within [2000-01-01, today + 1 year] | +| R022 | error | `lineItems.N.*` | non-negative quantity / price / amount | +| R023 | error | `lineItems` | no items but a non-zero total (structural) | +| R024 | warning | (the path) | model-flagged `_uncertain` paths | + +Regional rules (R010–R012) are grouped separately so a future settings screen +can toggle locales. A document is **rejected** only on a structural failure +(R023); other errors yield **needs-review**. diff --git a/src/schema/rules/arithmetic.ts b/src/schema/rules/arithmetic.ts new file mode 100644 index 0000000..34f8dfc --- /dev/null +++ b/src/schema/rules/arithmetic.ts @@ -0,0 +1,122 @@ +// Arithmetic rules. Integer minor units throughout; rounding tolerance of one +// minor unit per line. + +import { num, type Rule } from './engine.ts'; +import { approxEqualMinor, sumMinor } from './money.ts'; + +export const lineAmountRule: Rule = { + id: 'R001', + severity: 'error', + check: (invoice) => { + return invoice.lineItems.flatMap((line, i) => { + const qty = num(line.quantity); + const unit = num(line.unitPrice); + const amount = num(line.amount); + if (qty === null || unit === null || amount === null) return []; + const expected = qty * unit; + if (approxEqualMinor(expected, amount, 1)) return []; + return [ + { + ruleId: 'R001', + severity: 'error' as const, + fieldPath: `lineItems.${String(i)}.amount`, + message: `Line ${String(i + 1)}: quantity × unit price = ${expected.toFixed(2)}, but amount is ${amount.toFixed(2)}`, + suggestion: expected.toFixed(2), + }, + ]; + }); + }, +}; + +export const subtotalRule: Rule = { + id: 'R002', + severity: 'error', + check: (invoice) => { + const subtotal = num(invoice.subtotal); + const amounts = invoice.lineItems + .map((l) => num(l.amount)) + .filter((v): v is number => v !== null); + if (subtotal === null || amounts.length === 0) return []; + const sum = sumMinor(amounts); + if (approxEqualMinor(sum, subtotal, invoice.lineItems.length || 1)) return []; + return [ + { + ruleId: 'R002', + severity: 'error', + fieldPath: 'subtotal', + message: `Line amounts sum to ${sum.toFixed(2)}, but subtotal is ${subtotal.toFixed(2)}`, + suggestion: sum.toFixed(2), + }, + ]; + }, +}; + +export const totalRule: Rule = { + id: 'R003', + severity: 'error', + check: (invoice) => { + const subtotal = num(invoice.subtotal); + const tax = num(invoice.taxAmount); + const total = num(invoice.total); + if (subtotal === null || tax === null || total === null) return []; + const expected = sumMinor([subtotal, tax]); + if (approxEqualMinor(expected, total, 1)) return []; + return [ + { + ruleId: 'R003', + severity: 'error', + fieldPath: 'total', + message: `subtotal + tax = ${expected.toFixed(2)}, but total is ${total.toFixed(2)}`, + suggestion: expected.toFixed(2), + }, + ]; + }, +}; + +/** Most common per-line tax rate, when line rates exist. */ +function dominantTaxRate(rates: number[]): number | null { + if (rates.length === 0) return null; + const counts = new Map(); + for (const rate of rates) counts.set(rate, (counts.get(rate) ?? 0) + 1); + let best = rates[0] ?? null; + let bestCount = 0; + for (const [rate, count] of counts) { + if (count > bestCount) { + bestCount = count; + best = rate; + } + } + return best; +} + +export const taxConsistencyRule: Rule = { + id: 'R004', + severity: 'warning', + check: (invoice) => { + const subtotal = num(invoice.subtotal); + const tax = num(invoice.taxAmount); + const rates = invoice.lineItems + .map((l) => num(l.taxRate)) + .filter((v): v is number => v !== null); + const dominant = dominantTaxRate(rates); + if (subtotal === null || tax === null || dominant === null) return []; + const expected = subtotal * (dominant / 100); + if (approxEqualMinor(expected, tax, 2)) return []; + return [ + { + ruleId: 'R004', + severity: 'warning', + fieldPath: 'taxAmount', + message: `Tax at the dominant rate (${String(dominant)}%) would be ${expected.toFixed(2)}, but tax amount is ${tax.toFixed(2)}`, + suggestion: expected.toFixed(2), + }, + ]; + }, +}; + +export const arithmeticRules: readonly Rule[] = [ + lineAmountRule, + subtotalRule, + totalRule, + taxConsistencyRule, +]; diff --git a/src/schema/rules/engine.ts b/src/schema/rules/engine.ts new file mode 100644 index 0000000..a8b2a32 --- /dev/null +++ b/src/schema/rules/engine.ts @@ -0,0 +1,71 @@ +// Rule engine: runs every rule, aggregates findings, and computes per-field +// status. Pure TypeScript, zero ML — this is the trust boundary of Faturlens. + +import type { Field, InvoiceV1 } from '../invoice.ts'; + +export type Severity = 'error' | 'warning'; +export type FieldStatus = 'ok' | 'warning' | 'error'; + +export interface Finding { + ruleId: string; + severity: Severity; + fieldPath: string; + message: string; + /** Display-only hint. Never auto-applied. */ + suggestion?: string; +} + +export interface RuleContext { + /** Reference "now" for date plausibility, injectable for tests. */ + today: Date; +} + +export interface Rule { + id: string; + severity: Severity; + check: (invoice: InvoiceV1, ctx: RuleContext) => Finding[]; +} + +export interface ValidationResult { + findings: Finding[]; + /** Status by field path. Paths absent from the map are implicitly "ok". */ + fieldStatus: Record; +} + +/** Read a numeric field's value, or null. */ +export function num(field: Field | undefined): number | null { + return field?.value ?? null; +} + +/** Read a string field's value, or null. */ +export function str(field: Field | undefined): string | null { + return field?.value ?? null; +} + +export function statusFor(result: ValidationResult, fieldPath: string): FieldStatus { + return result.fieldStatus[fieldPath] ?? 'ok'; +} + +/** Run all rules and aggregate findings into per-field statuses. */ +export function runRules( + invoice: InvoiceV1, + rules: readonly Rule[], + ctx: RuleContext, +): ValidationResult { + const findings: Finding[] = []; + for (const rule of rules) { + findings.push(...rule.check(invoice, ctx)); + } + + const fieldStatus: Record = {}; + for (const finding of findings) { + const current = fieldStatus[finding.fieldPath]; + if (finding.severity === 'error') { + fieldStatus[finding.fieldPath] = 'error'; + } else if (current !== 'error') { + fieldStatus[finding.fieldPath] = 'warning'; + } + } + + return { findings, fieldStatus }; +} diff --git a/src/schema/rules/index.ts b/src/schema/rules/index.ts new file mode 100644 index 0000000..723ee0b --- /dev/null +++ b/src/schema/rules/index.ts @@ -0,0 +1,30 @@ +// Validation layer public surface: the rule registry plus a convenience runner. + +import type { InvoiceV1 } from '../invoice.ts'; +import { arithmeticRules } from './arithmetic.ts'; +import { runRules, type Rule, type RuleContext, type ValidationResult } from './engine.ts'; +import { regionalRules } from './regional.ts'; +import { foldReviewState, type ReviewState } from './review-state.ts'; +import { sanityRules } from './sanity.ts'; + +export * from './engine.ts'; +export * from './review-state.ts'; +export { arithmeticRules } from './arithmetic.ts'; +export { regionalRules } from './regional.ts'; +export { sanityRules } from './sanity.ts'; + +/** All rules, grouped so a settings screen can toggle locales later. */ +export const ALL_RULES: readonly Rule[] = [...arithmeticRules, ...regionalRules, ...sanityRules]; + +export interface Validation extends ValidationResult { + review: ReviewState; +} + +/** Run the full rule set and fold the result into a review state. */ +export function validateInvoice( + invoice: InvoiceV1, + ctx: RuleContext = { today: new Date() }, +): Validation { + const result = runRules(invoice, ALL_RULES, ctx); + return { ...result, review: foldReviewState(result) }; +} diff --git a/src/schema/rules/money.test.ts b/src/schema/rules/money.test.ts new file mode 100644 index 0000000..8169648 --- /dev/null +++ b/src/schema/rules/money.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { approxEqualMinor, sumMinor, toMinor } from './money.ts'; + +describe('toMinor', () => { + it('converts to integer minor units, rounding to nearest', () => { + expect(toMinor(1.5)).toBe(150); + expect(toMinor(100)).toBe(10000); + expect(toMinor(0.014)).toBe(1); // 1.4 minor units → 1 + }); +}); + +describe('approxEqualMinor', () => { + it('treats values within tolerance as equal', () => { + expect(approxEqualMinor(100.0, 100.01, 1)).toBe(true); + expect(approxEqualMinor(100.0, 100.02, 1)).toBe(false); + }); + it('uses a default tolerance of one minor unit', () => { + expect(approxEqualMinor(50.0, 50.01)).toBe(true); + }); +}); + +describe('sumMinor', () => { + it('sums without floating-point drift', () => { + expect(sumMinor([0.1, 0.2])).toBe(0.3); + expect(sumMinor([10.5, 20.25, 5.25])).toBe(36); + }); +}); diff --git a/src/schema/rules/money.ts b/src/schema/rules/money.ts new file mode 100644 index 0000000..f8f82c8 --- /dev/null +++ b/src/schema/rules/money.ts @@ -0,0 +1,20 @@ +// Money helpers. All comparisons use integer minor units (e.g. fils/cents) so +// we never test floating-point values for equality. + +const MINOR_FACTOR = 100; + +/** Convert a major-unit amount to integer minor units (2 dp). */ +export function toMinor(amount: number): number { + return Math.round(amount * MINOR_FACTOR); +} + +/** True when two major-unit amounts agree within `toleranceMinor` minor units. */ +export function approxEqualMinor(a: number, b: number, toleranceMinor = 1): boolean { + return Math.abs(toMinor(a) - toMinor(b)) <= toleranceMinor; +} + +/** Sum major-unit amounts in minor units, returned as a major-unit number. */ +export function sumMinor(amounts: number[]): number { + const total = amounts.reduce((sum, amount) => sum + toMinor(amount), 0); + return total / MINOR_FACTOR; +} diff --git a/src/schema/rules/regional.ts b/src/schema/rules/regional.ts new file mode 100644 index 0000000..3540b63 --- /dev/null +++ b/src/schema/rules/regional.ts @@ -0,0 +1,135 @@ +// Regional rules (UAE-focused, locale-groupable). Grouped separately so a future +// settings screen can toggle locales. + +import { num, str, type Rule } from './engine.ts'; +import { approxEqualMinor } from './money.ts'; + +const PREFERRED_CURRENCIES = new Set(['AED', 'USD', 'EUR', 'SAR', 'GBP', 'INR']); + +// A pragmatic ISO 4217 set for "valid but non-preferred" classification. +const ISO_4217 = new Set([ + 'AED', + 'USD', + 'EUR', + 'SAR', + 'GBP', + 'INR', + 'JPY', + 'CNY', + 'CHF', + 'CAD', + 'AUD', + 'KWD', + 'BHD', + 'OMR', + 'QAR', + 'EGP', + 'PKR', + 'BDT', + 'LKR', + 'SGD', + 'HKD', + 'MYR', + 'ZAR', + 'TRY', + 'RUB', + 'BRL', + 'MXN', + 'NZD', + 'SEK', + 'NOK', + 'DKK', + 'PLN', + 'THB', + 'IDR', + 'PHP', + 'KRW', +]); + +const UAE_VAT_RATE = 5; // percent + +export const trnFormatRule: Rule = { + id: 'R010', + severity: 'error', + check: (invoice) => { + const trn = str(invoice.vendor.trn); + if (trn === null) { + return [ + { + ruleId: 'R010', + severity: 'warning', + fieldPath: 'vendor.trn', + message: 'Vendor TRN is missing', + }, + ]; + } + if (!/^\d{15}$/.test(trn.trim())) { + return [ + { + ruleId: 'R010', + severity: 'error', + fieldPath: 'vendor.trn', + message: `TRN "${trn}" is not 15 digits`, + }, + ]; + } + return []; + }, +}; + +export const vatDefaultRule: Rule = { + id: 'R011', + severity: 'warning', + check: (invoice) => { + const currency = str(invoice.currency); + const subtotal = num(invoice.subtotal); + const tax = num(invoice.taxAmount); + if (currency !== 'AED' || subtotal === null || tax === null || subtotal === 0) return []; + const expected = subtotal * (UAE_VAT_RATE / 100); + if (approxEqualMinor(expected, tax, 2)) return []; + const effective = (tax / subtotal) * 100; + return [ + { + ruleId: 'R011', + severity: 'warning', + fieldPath: 'taxAmount', + message: `Effective VAT is ${effective.toFixed(1)}%; UAE invoices typically use ${String(UAE_VAT_RATE)}%`, + }, + ]; + }, +}; + +export const currencyWhitelistRule: Rule = { + id: 'R012', + severity: 'warning', + check: (invoice) => { + const currency = str(invoice.currency); + if (currency === null) return []; + const code = currency.trim().toUpperCase(); + if (PREFERRED_CURRENCIES.has(code)) return []; + if (ISO_4217.has(code)) { + return [ + { + ruleId: 'R012', + severity: 'warning', + fieldPath: 'currency', + message: `Currency ${code} is valid but outside the common set`, + }, + ]; + } + return [ + { + ruleId: 'R012', + severity: 'error', + fieldPath: 'currency', + message: `"${currency}" is not a recognized ISO 4217 currency code`, + }, + ]; + }, +}; + +export const regionalRules: readonly Rule[] = [ + trnFormatRule, + vatDefaultRule, + currencyWhitelistRule, +]; diff --git a/src/schema/rules/review-state.ts b/src/schema/rules/review-state.ts new file mode 100644 index 0000000..95c1f6c --- /dev/null +++ b/src/schema/rules/review-state.ts @@ -0,0 +1,49 @@ +// Fold validation findings into a review state. Never auto-corrects a value; +// suggestions are display-only. + +import type { Finding, ValidationResult } from './engine.ts'; + +export type ReviewVerdict = 'clean' | 'needs-review' | 'rejected'; + +export interface ReviewState { + verdict: ReviewVerdict; + /** Field paths a human must look at: any error, or a warning on a money field. */ + fieldsNeedingReview: string[]; + errorCount: number; + warningCount: number; +} + +const MONEY_FIELD = /^(subtotal|taxAmount|total)$|^lineItems\.\d+\.(amount|unitPrice)$/; + +// Findings severe enough to reject the document outright. +const STRUCTURAL_RULES = new Set(['R023']); + +function isMoneyField(path: string): boolean { + return MONEY_FIELD.test(path); +} + +export function foldReviewState(result: ValidationResult): ReviewState { + const errors = result.findings.filter((f) => f.severity === 'error'); + const warnings = result.findings.filter((f) => f.severity === 'warning'); + + const needing = new Set(); + for (const finding of errors) needing.add(finding.fieldPath); + for (const finding of warnings) { + if (isMoneyField(finding.fieldPath)) needing.add(finding.fieldPath); + } + + let verdict: ReviewVerdict; + if (errors.length === 0) { + verdict = warnings.length === 0 ? 'clean' : 'needs-review'; + } else { + const structural = errors.some((f: Finding) => STRUCTURAL_RULES.has(f.ruleId)); + verdict = structural ? 'rejected' : 'needs-review'; + } + + return { + verdict, + fieldsNeedingReview: [...needing], + errorCount: errors.length, + warningCount: warnings.length, + }; +} diff --git a/src/schema/rules/rules.test.ts b/src/schema/rules/rules.test.ts new file mode 100644 index 0000000..3322ff8 --- /dev/null +++ b/src/schema/rules/rules.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, it } from 'vitest'; +import type { Field, InvoiceV1, LineItem, Party } from '../invoice.ts'; +import { arithmeticRules } from './arithmetic.ts'; +import { runRules, statusFor, type Rule, type RuleContext } from './engine.ts'; +import { ALL_RULES, validateInvoice } from './index.ts'; +import { regionalRules } from './regional.ts'; +import { foldReviewState } from './review-state.ts'; +import { sanityRules } from './sanity.ts'; + +const ctx: RuleContext = { today: new Date('2026-06-07T00:00:00Z') }; + +function f(value: T | null, confidence: Field['confidence'] = 'extracted'): Field { + return { value, confidence }; +} + +function party(overrides: Partial = {}): Party { + return { + name: f('Acme FZE'), + address: f('Dubai, UAE'), + trn: f('100123456700003'), + phone: f(null, 'missing'), + email: f(null, 'missing'), + ...overrides, + }; +} + +function line(overrides: Partial = {}): LineItem { + return { + description: f('Widget'), + quantity: f(2), + unitPrice: f(50), + amount: f(100), + taxRate: f(5), + ...overrides, + }; +} + +function makeInvoice(overrides: Partial = {}): InvoiceV1 { + return { + vendor: party(), + invoiceNumber: f('INV-001'), + issueDate: f('2026-01-15'), + currency: f('AED'), + lineItems: [line()], + subtotal: f(100), + taxAmount: f(5), + total: f(105), + uncertain: [], + ...overrides, + }; +} + +const ids = (rules: readonly Rule[], invoice: InvoiceV1): string[] => + runRules(invoice, rules, ctx).findings.map((x) => x.ruleId); + +describe('engine', () => { + it('a clean invoice produces zero findings and a clean verdict', () => { + const v = validateInvoice(makeInvoice(), ctx); + expect(v.findings).toHaveLength(0); + expect(v.review.verdict).toBe('clean'); + }); + + it('error overrides warning on the same field path', () => { + const errorRule: Rule = { + id: 'E', + severity: 'error', + check: () => [{ ruleId: 'E', severity: 'error', fieldPath: 'total', message: 'e' }], + }; + const warnRule: Rule = { + id: 'W', + severity: 'warning', + check: () => [{ ruleId: 'W', severity: 'warning', fieldPath: 'total', message: 'w' }], + }; + const result = runRules(makeInvoice(), [warnRule, errorRule], ctx); + expect(statusFor(result, 'total')).toBe('error'); + expect(statusFor(result, 'subtotal')).toBe('ok'); + }); +}); + +describe('R001 line amount', () => { + it('passes when qty × unit = amount', () => { + expect(ids(arithmeticRules, makeInvoice())).not.toContain('R001'); + }); + it('tolerates a one-minor-unit rounding difference', () => { + expect( + ids(arithmeticRules, makeInvoice({ lineItems: [line({ amount: f(100.01) })] })), + ).not.toContain('R001'); + }); + it('flags a wrong line amount', () => { + expect(ids(arithmeticRules, makeInvoice({ lineItems: [line({ amount: f(90) })] }))).toContain( + 'R001', + ); + }); + it('skips lines with missing values', () => { + expect( + ids( + arithmeticRules, + makeInvoice({ lineItems: [line({ quantity: f(null, 'missing') })] }), + ), + ).not.toContain('R001'); + }); +}); + +describe('R002 subtotal', () => { + it('passes when line amounts sum to subtotal', () => { + expect(ids(arithmeticRules, makeInvoice())).not.toContain('R002'); + }); + it('flags a wrong subtotal', () => { + const inv = makeInvoice({ subtotal: f(80), total: f(85), taxAmount: f(5) }); + expect(ids(arithmeticRules, inv)).toContain('R002'); + }); +}); + +describe('R003 total', () => { + it('passes when subtotal + tax = total', () => { + expect(ids(arithmeticRules, makeInvoice())).not.toContain('R003'); + }); + it('flags a wrong total', () => { + expect(ids(arithmeticRules, makeInvoice({ total: f(999) }))).toContain('R003'); + }); +}); + +describe('R004 tax consistency', () => { + it('passes when tax matches the dominant rate', () => { + expect(ids(arithmeticRules, makeInvoice())).not.toContain('R004'); + }); + it('flags tax that disagrees with the dominant line rate', () => { + const inv = makeInvoice({ taxAmount: f(20), total: f(120) }); + expect(ids(arithmeticRules, inv)).toContain('R004'); + }); + it('uses the most common rate across mixed lines', () => { + const inv = makeInvoice({ + lineItems: [line({ taxRate: f(5) }), line({ taxRate: f(5) }), line({ taxRate: f(0) })], + subtotal: f(300), + taxAmount: f(0), + total: f(300), + }); + // dominant rate 5% → expected 15, tax 0 → flagged + expect(ids(arithmeticRules, inv)).toContain('R004'); + }); +}); + +describe('R010 TRN', () => { + it('passes a 15-digit TRN', () => { + expect(ids(regionalRules, makeInvoice())).not.toContain('R010'); + }); + it('errors on a malformed TRN', () => { + const inv = makeInvoice({ vendor: party({ trn: f('123') }) }); + const findings = runRules(inv, regionalRules, ctx).findings.filter((x) => x.ruleId === 'R010'); + expect(findings[0]?.severity).toBe('error'); + }); + it('warns when TRN is missing', () => { + const inv = makeInvoice({ vendor: party({ trn: f(null, 'missing') }) }); + const findings = runRules(inv, regionalRules, ctx).findings.filter((x) => x.ruleId === 'R010'); + expect(findings[0]?.severity).toBe('warning'); + }); +}); + +describe('R011 VAT default', () => { + it('passes at 5% in AED', () => { + expect(ids(regionalRules, makeInvoice())).not.toContain('R011'); + }); + it('warns on a non-5% effective rate in AED', () => { + expect(ids(regionalRules, makeInvoice({ taxAmount: f(10), total: f(110) }))).toContain('R011'); + }); + it('does not apply to non-AED currencies', () => { + const inv = makeInvoice({ currency: f('USD'), taxAmount: f(10), total: f(110) }); + expect(ids(regionalRules, inv)).not.toContain('R011'); + }); +}); + +describe('R012 currency whitelist', () => { + it('accepts a preferred currency', () => { + expect(ids(regionalRules, makeInvoice())).not.toContain('R012'); + }); + it('warns on a valid but non-preferred currency', () => { + const inv = makeInvoice({ currency: f('JPY') }); + const findings = runRules(inv, regionalRules, ctx).findings.filter((x) => x.ruleId === 'R012'); + expect(findings[0]?.severity).toBe('warning'); + }); + it('errors on an invalid code', () => { + const inv = makeInvoice({ currency: f('XYZ') }); + const findings = runRules(inv, regionalRules, ctx).findings.filter((x) => x.ruleId === 'R012'); + expect(findings[0]?.severity).toBe('error'); + }); +}); + +describe('R020 date order', () => { + it('passes when due ≥ issue', () => { + expect(ids(sanityRules, makeInvoice({ dueDate: f('2026-02-15') }))).not.toContain('R020'); + }); + it('errors when due < issue', () => { + expect(ids(sanityRules, makeInvoice({ dueDate: f('2026-01-01') }))).toContain('R020'); + }); +}); + +describe('R021 date plausibility', () => { + it('passes a current date', () => { + expect(ids(sanityRules, makeInvoice())).not.toContain('R021'); + }); + it('accepts the 2000-01-01 boundary', () => { + expect(ids(sanityRules, makeInvoice({ issueDate: f('2000-01-01') }))).not.toContain('R021'); + }); + it('errors before 2000', () => { + expect(ids(sanityRules, makeInvoice({ issueDate: f('1999-12-31') }))).toContain('R021'); + }); + it('errors more than a year in the future', () => { + expect(ids(sanityRules, makeInvoice({ issueDate: f('2028-01-01') }))).toContain('R021'); + }); +}); + +describe('R022 non-negative', () => { + it('passes positive values', () => { + expect(ids(sanityRules, makeInvoice())).not.toContain('R022'); + }); + it('errors on a negative quantity', () => { + const inv = makeInvoice({ lineItems: [line({ quantity: f(-1), amount: f(-50) })] }); + expect(ids(sanityRules, inv)).toContain('R022'); + }); +}); + +describe('R023 empty line items', () => { + it('errors when there are no items but a non-zero total', () => { + expect(ids(sanityRules, makeInvoice({ lineItems: [], total: f(105) }))).toContain('R023'); + }); + it('passes when empty items and zero total', () => { + expect(ids(sanityRules, makeInvoice({ lineItems: [], total: f(0) }))).not.toContain('R023'); + }); +}); + +describe('R024 uncertain paths', () => { + it('warns once per uncertain path', () => { + const inv = makeInvoice({ uncertain: ['subtotal', 'vendor.name'] }); + const findings = runRules(inv, sanityRules, ctx).findings.filter((x) => x.ruleId === 'R024'); + expect(findings).toHaveLength(2); + }); +}); + +describe('review state', () => { + it('clean with no findings', () => { + const review = foldReviewState({ findings: [], fieldStatus: {} }); + expect(review.verdict).toBe('clean'); + }); + it('needs-review with only warnings', () => { + const review = validateInvoice(makeInvoice({ currency: f('JPY') }), ctx).review; + expect(review.verdict).toBe('needs-review'); + }); + it('needs-review and flags the money field on a wrong subtotal', () => { + const v = validateInvoice(makeInvoice({ subtotal: f(80) }), ctx); + expect(v.findings.some((x) => x.ruleId === 'R002')).toBe(true); + expect(v.review.verdict).toBe('needs-review'); + expect(v.review.fieldsNeedingReview).toContain('subtotal'); + }); + it('rejected on a structural failure (empty items with a total)', () => { + const v = validateInvoice(makeInvoice({ lineItems: [], subtotal: f(0), total: f(105) }), ctx); + expect(v.review.verdict).toBe('rejected'); + }); +}); + +describe('registry', () => { + it('exposes all rule groups', () => { + expect(ALL_RULES.length).toBe( + arithmeticRules.length + regionalRules.length + sanityRules.length, + ); + }); +}); diff --git a/src/schema/rules/sanity.ts b/src/schema/rules/sanity.ts new file mode 100644 index 0000000..1d9803e --- /dev/null +++ b/src/schema/rules/sanity.ts @@ -0,0 +1,111 @@ +// Sanity rules: date ordering/plausibility, non-negative values, structural +// checks, and uncertain-path surfacing. + +import { num, str, type Rule } from './engine.ts'; + +const MIN_DATE = new Date('2000-01-01T00:00:00Z').getTime(); + +function parseIsoDate(value: string | null): number | null { + if (value === null) return null; + const ts = Date.parse(value); + return Number.isNaN(ts) ? null : ts; +} + +export const dateOrderRule: Rule = { + id: 'R020', + severity: 'error', + check: (invoice) => { + const issue = parseIsoDate(str(invoice.issueDate)); + const due = parseIsoDate(invoice.dueDate?.value ?? null); + if (issue === null || due === null) return []; + if (due >= issue) return []; + return [ + { + ruleId: 'R020', + severity: 'error', + fieldPath: 'dueDate', + message: 'Due date is before the issue date', + }, + ]; + }, +}; + +export const datePlausibilityRule: Rule = { + id: 'R021', + severity: 'error', + check: (invoice, ctx) => { + const issue = parseIsoDate(str(invoice.issueDate)); + if (issue === null) return []; + const upper = new Date(ctx.today); + upper.setFullYear(upper.getFullYear() + 1); + if (issue >= MIN_DATE && issue <= upper.getTime()) return []; + return [ + { + ruleId: 'R021', + severity: 'error', + fieldPath: 'issueDate', + message: 'Issue date is implausible (before 2000 or more than a year in the future)', + }, + ]; + }, +}; + +export const nonNegativeRule: Rule = { + id: 'R022', + severity: 'error', + check: (invoice) => { + return invoice.lineItems.flatMap((line, i) => { + const checks: { key: 'quantity' | 'unitPrice' | 'amount'; value: number | null }[] = [ + { key: 'quantity', value: num(line.quantity) }, + { key: 'unitPrice', value: num(line.unitPrice) }, + { key: 'amount', value: num(line.amount) }, + ]; + return checks + .filter((c) => c.value !== null && c.value < 0) + .map((c) => ({ + ruleId: 'R022', + severity: 'error' as const, + fieldPath: `lineItems.${String(i)}.${c.key}`, + message: `Line ${String(i + 1)} ${c.key} is negative`, + })); + }); + }, +}; + +export const emptyLineItemsRule: Rule = { + id: 'R023', + severity: 'error', + check: (invoice) => { + const total = num(invoice.total); + if (invoice.lineItems.length > 0 || total === null || total === 0) return []; + return [ + { + ruleId: 'R023', + severity: 'error', + fieldPath: 'lineItems', + message: `No line items, but total is ${total.toFixed(2)}`, + }, + ]; + }, +}; + +export const uncertainPathRule: Rule = { + id: 'R024', + severity: 'warning', + check: (invoice) => { + return invoice.uncertain.map((path) => ({ + ruleId: 'R024', + severity: 'warning' as const, + fieldPath: path, + message: 'The model flagged this value as uncertain', + })); + }, +}; + +export const sanityRules: readonly Rule[] = [ + dateOrderRule, + datePlausibilityRule, + nonNegativeRule, + emptyLineItemsRule, + uncertainPathRule, +]; diff --git a/src/store/README.md b/src/store/README.md index 1c03c97..a5be4fa 100644 --- a/src/store/README.md +++ b/src/store/README.md @@ -2,9 +2,36 @@ Dexie (IndexedDB) persistence and the sequential processing queue. -Holds documents, pages, edit audit trails, and settings. Source file bytes are -stored as blobs so all state survives reload with no network. The queue runs at -concurrency 1 (matching the worker invariant) and persists its state so a reload -resumes where it stopped. +## Schema (v1) -_Populated in Phase 11._ +- `documents` — `{ id, fileName, fileHash, pageCount, createdAt, status, fileBlob }` +- `pages` — `{ id, documentId, pageIndex, imageBlob, transcript, transcriptQuality, extraction, findings, reviewState }` +- `edits` — `{ id, pageId, fieldPath, before, after, at }` +- `settings` — `{ key, value }` + +Source bytes are stored as blobs so all state survives reload with **no network +and no reprocessing**. `createRepository(db)` wraps a `FaturlensDB` instance; +`repository` is the app-wide default. Tests use `fake-indexeddb` with a fresh +named DB per case. + +## Queue + +`queue.ts` is a **pure** state machine (concurrency 1, matching the worker +invariant): `queued → preprocessing → pass1 → pass2 → validating → +ready-for-review`, plus `failed`. It tracks `lastCompletedStage` so a reload or +retry resumes the in-flight item from the stage **after** the last completed one +(`resumeStage`). Pause/resume toggle a flag. + +## Dedup & storage + +- `dedup.ts` — `checkDuplicate()` hashes bytes (sha256) and looks for an existing + document, so a re-upload offers "open existing" instead of reprocessing. +- `storage.ts` — `estimateStorage()` via `navigator.storage.estimate()`, warning + at 80%. +- `deleteDocumentCascade()` removes a document with its pages and edits in one + transaction. + +## Migration policy + +Bump `this.version(N).stores(...)` with an upgrade function for any schema +change; never mutate the v1 store definition in place. diff --git a/src/store/db.test.ts b/src/store/db.test.ts new file mode 100644 index 0000000..6fe2205 --- /dev/null +++ b/src/store/db.test.ts @@ -0,0 +1,76 @@ +import 'fake-indexeddb/auto'; +import { describe, expect, it } from 'vitest'; +import { createRepository, FaturlensDB, type DocumentRecord, type PageRecord } from './db.ts'; +import { checkDuplicate } from './dedup.ts'; + +let dbCounter = 0; +function freshRepo() { + dbCounter += 1; + return createRepository(new FaturlensDB(`faturlens-test-${String(dbCounter)}`)); +} + +function doc(id: string, hash: string): DocumentRecord { + return { + id, + fileName: `${id}.pdf`, + fileHash: hash, + pageCount: 1, + createdAt: dbCounter, + status: 'ready-for-review', + fileBlob: new Blob(['x']), + }; +} +function page(id: string, documentId: string, pageIndex: number): PageRecord { + return { id, documentId, pageIndex, imageBlob: new Blob(['p']) }; +} + +describe('repository CRUD', () => { + it('adds and reads a document', async () => { + const repo = freshRepo(); + await repo.addDocument(doc('d1', 'hashA')); + expect((await repo.getDocument('d1'))?.fileName).toBe('d1.pdf'); + }); + + it('lists pages for a document sorted by index', async () => { + const repo = freshRepo(); + await repo.addPage(page('p2', 'd1', 1)); + await repo.addPage(page('p1', 'd1', 0)); + const pages = await repo.pagesForDocument('d1'); + expect(pages.map((p) => p.pageIndex)).toEqual([0, 1]); + }); +}); + +describe('dedup', () => { + it('detects an existing document by content hash', async () => { + const repo = freshRepo(); + const bytes = new Uint8Array([1, 2, 3, 4]); + const first = await checkDuplicate(repo, bytes); + expect(first.existing).toBeUndefined(); + await repo.addDocument(doc('d1', first.hash)); + const second = await checkDuplicate(repo, bytes); + expect(second.existing?.id).toBe('d1'); + }); +}); + +describe('cascade deletion', () => { + it('removes pages and edits along with the document', async () => { + const repo = freshRepo(); + await repo.addDocument(doc('d1', 'hashA')); + await repo.addPage(page('p1', 'd1', 0)); + await repo.addEdit({ id: 'e1', pageId: 'p1', fieldPath: 'total', before: 1, after: 2, at: 1 }); + + await repo.deleteDocumentCascade('d1'); + + expect(await repo.getDocument('d1')).toBeUndefined(); + expect(await repo.pagesForDocument('d1')).toHaveLength(0); + expect(await repo.editsForPage('p1')).toHaveLength(0); + }); +}); + +describe('settings', () => { + it('round-trips a setting value', async () => { + const repo = freshRepo(); + await repo.setSetting('tokensPerSecond', 7.5); + expect(await repo.getSetting('tokensPerSecond')).toBe(7.5); + }); +}); diff --git a/src/store/db.ts b/src/store/db.ts new file mode 100644 index 0000000..fe0ac62 --- /dev/null +++ b/src/store/db.ts @@ -0,0 +1,124 @@ +// Dexie (IndexedDB) schema v1 and repositories. Source bytes are stored as +// blobs so all state survives reload with no network. + +import Dexie, { type Table } from 'dexie'; + +export type DocumentStatus = 'queued' | 'processing' | 'ready-for-review' | 'approved' | 'failed'; + +export interface DocumentRecord { + id: string; + fileName: string; + fileHash: string; + pageCount: number; + createdAt: number; + status: DocumentStatus; + fileBlob: Blob; +} + +export interface PageRecord { + id: string; + documentId: string; + pageIndex: number; + imageBlob: Blob; + transcript?: string; + transcriptQuality?: unknown; + extraction?: unknown; + findings?: unknown; + reviewState?: unknown; +} + +export interface EditRecord { + id: string; + pageId: string; + fieldPath: string; + before: string | number | null; + after: string | number | null; + at: number; +} + +export interface SettingRecord { + key: string; + value: unknown; +} + +export class FaturlensDB extends Dexie { + documents!: Table; + pages!: Table; + edits!: Table; + settings!: Table; + + constructor(name = 'faturlens') { + super(name); + this.version(1).stores({ + documents: 'id, fileHash, status, createdAt', + pages: 'id, documentId, [documentId+pageIndex]', + edits: 'id, pageId', + settings: 'key', + }); + } +} + +export interface Repository { + db: FaturlensDB; + addDocument: (doc: DocumentRecord) => Promise; + getDocument: (id: string) => Promise; + findByHash: (hash: string) => Promise; + allDocuments: () => Promise; + setDocumentStatus: (id: string, status: DocumentStatus) => Promise; + addPage: (page: PageRecord) => Promise; + pagesForDocument: (documentId: string) => Promise; + addEdit: (edit: EditRecord) => Promise; + editsForPage: (pageId: string) => Promise; + deleteDocumentCascade: (documentId: string) => Promise; + getSetting: (key: string) => Promise; + setSetting: (key: string, value: unknown) => Promise; +} + +export function createRepository(database: FaturlensDB): Repository { + return { + db: database, + addDocument: async (doc) => { + await database.documents.put(doc); + }, + getDocument: (id) => database.documents.get(id), + findByHash: (hash) => database.documents.where('fileHash').equals(hash).first(), + allDocuments: () => database.documents.orderBy('createdAt').toArray(), + setDocumentStatus: async (id, status) => { + await database.documents.update(id, { status }); + }, + addPage: async (page) => { + await database.pages.put(page); + }, + pagesForDocument: (documentId) => + database.pages.where('documentId').equals(documentId).sortBy('pageIndex'), + addEdit: async (edit) => { + await database.edits.put(edit); + }, + editsForPage: (pageId) => database.edits.where('pageId').equals(pageId).toArray(), + deleteDocumentCascade: async (documentId) => { + await database.transaction( + 'rw', + database.documents, + database.pages, + database.edits, + async () => { + const pages = await database.pages.where('documentId').equals(documentId).toArray(); + const pageIds = pages.map((p) => p.id); + await database.edits.where('pageId').anyOf(pageIds).delete(); + await database.pages.where('documentId').equals(documentId).delete(); + await database.documents.delete(documentId); + }, + ); + }, + getSetting: async (key: string): Promise => { + const record = await database.settings.get(key); + return record?.value as T | undefined; + }, + setSetting: async (key, value) => { + await database.settings.put({ key, value }); + }, + }; +} + +/** The application-wide repository (default IndexedDB database). */ +export const repository: Repository = createRepository(new FaturlensDB()); diff --git a/src/store/dedup.ts b/src/store/dedup.ts new file mode 100644 index 0000000..7b5436e --- /dev/null +++ b/src/store/dedup.ts @@ -0,0 +1,19 @@ +// Upload de-duplication by content hash. + +import { sha256Hex } from '../lib/hash.ts'; +import type { DocumentRecord, Repository } from './db.ts'; + +export interface DedupResult { + hash: string; + existing: DocumentRecord | undefined; +} + +/** Hash bytes and look for an existing document with the same content. */ +export async function checkDuplicate( + repo: Repository, + bytes: ArrayBuffer | Uint8Array, +): Promise { + const hash = await sha256Hex(bytes); + const existing = await repo.findByHash(hash); + return { hash, existing }; +} diff --git a/src/store/hooks.ts b/src/store/hooks.ts new file mode 100644 index 0000000..6e95677 --- /dev/null +++ b/src/store/hooks.ts @@ -0,0 +1,16 @@ +// liveQuery hooks for documents and the processing queue. + +import { useLiveQuery } from 'dexie-react-hooks'; +import { repository, type DocumentRecord } from './db.ts'; + +export function useDocuments(): DocumentRecord[] { + return useLiveQuery(() => repository.allDocuments(), [], []); +} + +export function useQueue(): DocumentRecord[] { + return useLiveQuery( + () => repository.db.documents.where('status').anyOf('queued', 'processing').toArray(), + [], + [], + ); +} diff --git a/src/store/queue.test.ts b/src/store/queue.test.ts new file mode 100644 index 0000000..792f3b5 --- /dev/null +++ b/src/store/queue.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { + createQueueItem, + isTerminal, + nextStage, + queueReducer, + resumeStage, + type QueueItem, +} from './queue.ts'; + +const start = (): QueueItem => createQueueItem('q1', 'doc1'); + +describe('nextStage', () => { + it('advances through the ordered stages', () => { + expect(nextStage('queued')).toBe('preprocessing'); + expect(nextStage('pass1')).toBe('pass2'); + expect(nextStage('validating')).toBe('ready-for-review'); + }); + it('stays put at the terminal stage', () => { + expect(nextStage('ready-for-review')).toBe('ready-for-review'); + }); +}); + +describe('queueReducer — advance', () => { + it('records the completed stage as it advances', () => { + let item = start(); + item = queueReducer(item, { type: 'advance' }); // → preprocessing + expect(item.stage).toBe('preprocessing'); + expect(item.lastCompletedStage).toBe('queued'); + item = queueReducer(item, { type: 'advance' }); // → pass1 + expect(item.stage).toBe('pass1'); + expect(item.lastCompletedStage).toBe('preprocessing'); + }); + + it('does not advance past ready-for-review', () => { + let item: QueueItem = { ...start(), stage: 'ready-for-review' }; + item = queueReducer(item, { type: 'advance' }); + expect(item.stage).toBe('ready-for-review'); + expect(isTerminal(item.stage)).toBe(true); + }); +}); + +describe('queueReducer — failure and retry', () => { + it('fails with an error message while preserving resume point', () => { + let item = start(); + item = queueReducer(item, { type: 'advance' }); // preprocessing, completed queued + item = queueReducer(item, { type: 'advance' }); // pass1, completed preprocessing + item = queueReducer(item, { type: 'fail', error: 'OOM' }); + expect(item.stage).toBe('failed'); + expect(item.error).toBe('OOM'); + expect(item.lastCompletedStage).toBe('preprocessing'); + }); + + it('retries from the stage after the last completed one', () => { + let item: QueueItem = { + ...start(), + stage: 'failed', + lastCompletedStage: 'preprocessing', + error: 'OOM', + }; + item = queueReducer(item, { type: 'retry' }); + expect(item.stage).toBe('pass1'); + expect(item.error).toBeNull(); + }); + + it('ignores retry when not failed', () => { + const item = queueReducer(start(), { type: 'retry' }); + expect(item.stage).toBe('queued'); + }); +}); + +describe('resumeStage (reload resume)', () => { + it('restarts in-flight items from preprocessing when nothing completed', () => { + expect(resumeStage(start())).toBe('preprocessing'); + }); + it('resumes from the stage after the last completed one', () => { + expect(resumeStage({ ...start(), lastCompletedStage: 'pass1' })).toBe('pass2'); + }); +}); + +describe('queueReducer — pause/resume', () => { + it('toggles the paused flag', () => { + let item = queueReducer(start(), { type: 'pause' }); + expect(item.paused).toBe(true); + item = queueReducer(item, { type: 'resume' }); + expect(item.paused).toBe(false); + }); +}); diff --git a/src/store/queue.ts b/src/store/queue.ts new file mode 100644 index 0000000..f689525 --- /dev/null +++ b/src/store/queue.ts @@ -0,0 +1,83 @@ +// Sequential processing queue state machine. Concurrency 1 (matching the worker +// invariant). Pure transitions so they are unit-tested; persistence and the +// actual stage work live elsewhere. + +export type Stage = + | 'queued' + | 'preprocessing' + | 'pass1' + | 'pass2' + | 'validating' + | 'ready-for-review' + | 'failed'; + +/** Ordered processing stages (excludes the terminal "failed"). */ +export const STAGE_ORDER: readonly Stage[] = [ + 'queued', + 'preprocessing', + 'pass1', + 'pass2', + 'validating', + 'ready-for-review', +]; + +export interface QueueItem { + id: string; + documentId: string; + stage: Stage; + /** Last stage that completed successfully (for reload/retry resume). */ + lastCompletedStage: Stage | null; + paused: boolean; + error: string | null; +} + +export type QueueAction = + | { type: 'advance' } + | { type: 'fail'; error: string } + | { type: 'retry' } + | { type: 'pause' } + | { type: 'resume' }; + +export function createQueueItem(id: string, documentId: string): QueueItem { + return { id, documentId, stage: 'queued', lastCompletedStage: null, paused: false, error: null }; +} + +export function nextStage(stage: Stage): Stage { + const index = STAGE_ORDER.indexOf(stage); + if (index === -1 || index === STAGE_ORDER.length - 1) return stage; + return STAGE_ORDER[index + 1] ?? stage; +} + +export function isTerminal(stage: Stage): boolean { + return stage === 'ready-for-review' || stage === 'failed'; +} + +/** + * The stage a paused/reloaded/failed item should re-run from: the one after the + * last completed stage (or the first stage if none completed). + */ +export function resumeStage(item: QueueItem): Stage { + if (item.lastCompletedStage === null) return 'preprocessing'; + return nextStage(item.lastCompletedStage); +} + +export function queueReducer(item: QueueItem, action: QueueAction): QueueItem { + switch (action.type) { + case 'advance': { + if (isTerminal(item.stage)) return item; + const completed = item.stage; + return { ...item, stage: nextStage(item.stage), lastCompletedStage: completed, error: null }; + } + case 'fail': + return { ...item, stage: 'failed', error: action.error }; + case 'retry': + if (item.stage !== 'failed') return item; + return { ...item, stage: resumeStage(item), error: null }; + case 'pause': + return { ...item, paused: true }; + case 'resume': + return { ...item, paused: false }; + default: + return item; + } +} diff --git a/src/store/resumable.test.ts b/src/store/resumable.test.ts new file mode 100644 index 0000000..6ba04e0 --- /dev/null +++ b/src/store/resumable.test.ts @@ -0,0 +1,29 @@ +import 'fake-indexeddb/auto'; +import { describe, expect, it } from 'vitest'; +import { createRepository, FaturlensDB, type DocumentRecord } from './db.ts'; +import { markInFlightResumable } from './resumable.ts'; + +function doc(id: string, status: DocumentRecord['status']): DocumentRecord { + return { + id, + fileName: `${id}.pdf`, + fileHash: id, + pageCount: 1, + createdAt: 1, + status, + fileBlob: new Blob(['x']), + }; +} + +describe('markInFlightResumable', () => { + it('resets processing documents to queued', async () => { + const repo = createRepository(new FaturlensDB('faturlens-resumable-test')); + await repo.addDocument(doc('a', 'processing')); + await repo.addDocument(doc('b', 'ready-for-review')); + + const count = await markInFlightResumable(repo); + expect(count).toBe(1); + expect((await repo.getDocument('a'))?.status).toBe('queued'); + expect((await repo.getDocument('b'))?.status).toBe('ready-for-review'); + }); +}); diff --git a/src/store/resumable.ts b/src/store/resumable.ts new file mode 100644 index 0000000..a5e971b --- /dev/null +++ b/src/store/resumable.ts @@ -0,0 +1,21 @@ +// Mark in-flight work resumable so an abrupt tab close never corrupts the queue: +// any document left "processing" is reset to "queued" for a clean resume. + +import { repository, type Repository } from './db.ts'; + +export async function markInFlightResumable(repo: Repository = repository): Promise { + const processing = await repo.db.documents.where('status').equals('processing').toArray(); + await Promise.all(processing.map((doc) => repo.setDocumentStatus(doc.id, 'queued'))); + return processing.length; +} + +/** Register a beforeunload handler that flags in-flight work resumable. */ +export function registerResumableOnUnload(repo: Repository = repository): () => void { + const handler = (): void => { + void markInFlightResumable(repo); + }; + globalThis.addEventListener('beforeunload', handler); + return () => { + globalThis.removeEventListener('beforeunload', handler); + }; +} diff --git a/src/store/storage.ts b/src/store/storage.ts new file mode 100644 index 0000000..edff169 --- /dev/null +++ b/src/store/storage.ts @@ -0,0 +1,23 @@ +// Storage budget estimation via navigator.storage.estimate(). + +export interface StorageEstimateResult { + usage: number; + quota: number; + fraction: number; + /** True at or above the 80% warning threshold. */ + warn: boolean; +} + +export const STORAGE_WARN_FRACTION = 0.8; + +export function classifyEstimate(usage: number, quota: number): StorageEstimateResult { + const fraction = quota > 0 ? usage / quota : 0; + return { usage, quota, fraction, warn: fraction >= STORAGE_WARN_FRACTION }; +} + +export async function estimateStorage(): Promise { + const storage = (globalThis.navigator as Navigator | undefined)?.storage; + if (!storage?.estimate) return null; + const { usage = 0, quota = 0 } = await storage.estimate(); + return classifyEstimate(usage, quota); +} diff --git a/src/ui/DocumentList.module.css b/src/ui/DocumentList.module.css new file mode 100644 index 0000000..7cfc502 --- /dev/null +++ b/src/ui/DocumentList.module.css @@ -0,0 +1,66 @@ +.list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.6rem 0.85rem; + border: 1px solid var(--fl-border, #d8d8d8); + border-radius: var(--fl-radius, 0.5rem); + background: var(--fl-surface-raised, #fff); +} + +.name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chip { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.12rem 0.5rem; + border-radius: 999px; + color: #fff; +} +.queued { + background: #6a6a6a; +} +.processing { + background: #9a6700; +} +.ready-for-review { + background: #1f6feb; +} +.approved { + background: #1a7f37; +} +.failed { + background: #b42318; +} + +.delete, +.retry { + font: inherit; + font-size: 0.8rem; + padding: 0.25rem 0.6rem; + border-radius: var(--fl-radius, 0.5rem); + border: 1px solid var(--fl-border, #bbb); + background: transparent; + color: inherit; + cursor: pointer; +} + +.empty { + color: var(--fl-text-muted, #6a6a6a); + font-size: 0.9rem; + padding: 1rem; +} diff --git a/src/ui/DocumentList.tsx b/src/ui/DocumentList.tsx new file mode 100644 index 0000000..5fbfb91 --- /dev/null +++ b/src/ui/DocumentList.tsx @@ -0,0 +1,72 @@ +import { cx } from '../lib/cx.ts'; +import { repository, type DocumentRecord, type DocumentStatus } from '../store/db.ts'; +import { useDocuments } from '../store/hooks.ts'; +import styles from './DocumentList.module.css'; + +const chipClass: Record = { + queued: styles.queued ?? '', + processing: styles.processing ?? '', + 'ready-for-review': styles['ready-for-review'] ?? '', + approved: styles.approved ?? '', + failed: styles.failed ?? '', +}; + +export interface DocumentListProps { + onOpen?: (doc: DocumentRecord) => void; +} + +/** Document list with status chips, retry on failure, and confirmed deletion. */ +export function DocumentList({ onOpen }: DocumentListProps): React.JSX.Element { + const documents = useDocuments(); + + const remove = (doc: DocumentRecord): void => { + if (!confirm(`Delete "${doc.fileName}" and all its pages and edits?`)) return; + void repository.deleteDocumentCascade(doc.id); + }; + const retry = (doc: DocumentRecord): void => { + void repository.setDocumentStatus(doc.id, 'queued'); + }; + + if (documents.length === 0) { + return

No documents yet. Upload an invoice to get started.

; + } + + return ( +
    + {documents.map((doc) => ( +
  • + + {doc.status} + {doc.status === 'failed' ? ( + + ) : null} + +
  • + ))} +
+ ); +} diff --git a/src/ui/ErrorBoundary.tsx b/src/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..9aa1244 --- /dev/null +++ b/src/ui/ErrorBoundary.tsx @@ -0,0 +1,53 @@ +import { Component, type ErrorInfo, type ReactNode } from 'react'; +import { APP_VERSION } from '../lib/version.ts'; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; + info: string; +} + +/** Global error boundary with a copyable diagnostic block (no raw stack in UI). */ +export class ErrorBoundary extends Component { + override state: State = { error: null, info: '' }; + + static getDerivedStateFromError(error: Error): State { + return { error, info: '' }; + } + + override componentDidCatch(error: Error, info: ErrorInfo): void { + this.setState({ error, info: info.componentStack ?? '' }); + } + + private diagnostic(): string { + const { error, info } = this.state; + return [ + `Faturlens ${APP_VERSION}`, + `Error: ${error?.message ?? 'unknown'}`, + `Stack: ${error?.stack ?? 'n/a'}`, + `Component: ${info}`, + ].join('\n'); + } + + override render(): ReactNode { + if (!this.state.error) return this.props.children; + return ( +
+

Something went wrong

+

Faturlens hit an unexpected error. Your data stays on this device.

+ +
+ ); + } +} diff --git a/src/ui/Footer.test.tsx b/src/ui/Footer.test.tsx new file mode 100644 index 0000000..03367f3 --- /dev/null +++ b/src/ui/Footer.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { APP_VERSION } from '../lib/version.ts'; +import { ErrorBoundary } from './ErrorBoundary.tsx'; +import { Footer } from './Footer.tsx'; + +describe('Footer', () => { + it('shows version, model id, and prompt versions', () => { + render(