From 379ed232df3bb2a05a2073373e99a2f616e1f2ec Mon Sep 17 00:00:00 2001 From: grabowski Date: Wed, 22 Apr 2026 15:58:57 +0700 Subject: [PATCH] feat: Add SvelteKit web app with scan sessions and import queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Flask/Alpine web app with a SvelteKit 2 + Svelte 5 rewrite under web/, built on adapter-node and Tailwind v4. Same shape as the reference b4l budget app — no auth, stateless pass-through to InvenTree. New "scan session" flow groups mass scans into a session with live counters (scanned / succeeded / pending / failed). Unknown parts in import mode are fed to a worker pool that spawns inventree-part-import (IMPORT_CONCURRENCY, default 3, with 3-retry). Anything that can't be resolved automatically — parse errors, missing qty, invalid location, API errors, or imports that exhaust retries — drops into a Failures panel with a per-item Fix dialog (edit fields / search existing part / retry import). CSV export on the failure list. Layout is two-column on lg+: scanner + activity on the left, pending imports + failures on the right. Light-theme default. SSE on /api/events streams session and import events to the client. Barcode parser ported from the Python/JS versions and hardened for Digi-Key MH10.8.2 barcodes both with and without GS separators (old parser greedy-matched Q's digits and read "Q6" + "11ZPICK" as 611). Import worker also now treats a subprocess failure followed by a successful findPart as a success, so partial imports (part created but a duplicate parameter trips the DB constraint) no longer land in the Failures panel. Deploy artifacts: systemd unit, nginx example (SSE-friendly), and a step-by-step deploy/README. Requires inventree-part-import >= 1.9.2 on the server for InvenTree 1.x API compatibility. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.local.json | 11 +- web/.env.example | 17 + web/.gitignore | 8 + web/README.md | 73 + web/deploy/README.md | 86 + web/deploy/inventree-stock-tool.service | 32 + web/deploy/nginx.conf.example | 33 + web/package-lock.json | 2481 +++++++++++++++++ web/package.json | 26 + web/scripts/test-barcode.mjs | 94 + web/src/app.css | 11 + web/src/app.d.ts | 12 + web/src/app.html | 13 + web/src/lib/barcode.ts | 178 ++ web/src/lib/client.ts | 136 + web/src/lib/components/ActivityLog.svelte | 43 + .../lib/components/ConnectionStatus.svelte | 24 + .../lib/components/FailureFixDialog.svelte | 198 ++ web/src/lib/components/FailureList.svelte | 103 + web/src/lib/components/LocationPicker.svelte | 66 + web/src/lib/components/ModeSelector.svelte | 32 + web/src/lib/components/PendingList.svelte | 86 + web/src/lib/components/ScanInput.svelte | 46 + web/src/lib/components/SessionPanel.svelte | 62 + web/src/lib/components/Stat.svelte | 23 + web/src/lib/server/barcode.ts | 2 + web/src/lib/server/env.ts | 54 + web/src/lib/server/events.ts | 44 + web/src/lib/server/importQueue.ts | 194 ++ web/src/lib/server/inventree.ts | 195 ++ web/src/lib/server/sessions.ts | 459 +++ web/src/lib/stores/app.svelte.ts | 72 + web/src/lib/types.ts | 131 + web/src/routes/+layout.svelte | 7 + web/src/routes/+page.svelte | 217 ++ web/src/routes/api/config/+server.ts | 13 + web/src/routes/api/events/+server.ts | 13 + web/src/routes/api/locations/+server.ts | 13 + web/src/routes/api/part/search/+server.ts | 19 + web/src/routes/api/proxy/image/+server.ts | 21 + web/src/routes/api/session/end/+server.ts | 11 + .../routes/api/session/failures/+server.ts | 22 + .../routes/api/session/location/+server.ts | 14 + web/src/routes/api/session/mode/+server.ts | 18 + web/src/routes/api/session/retry/+server.ts | 21 + web/src/routes/api/session/scan/+server.ts | 30 + web/src/routes/api/session/start/+server.ts | 21 + web/svelte.config.js | 12 + web/tsconfig.json | 14 + web/vite.config.ts | 7 + 50 files changed, 5517 insertions(+), 1 deletion(-) create mode 100644 web/.env.example create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/deploy/README.md create mode 100644 web/deploy/inventree-stock-tool.service create mode 100644 web/deploy/nginx.conf.example create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/scripts/test-barcode.mjs create mode 100644 web/src/app.css create mode 100644 web/src/app.d.ts create mode 100644 web/src/app.html create mode 100644 web/src/lib/barcode.ts create mode 100644 web/src/lib/client.ts create mode 100644 web/src/lib/components/ActivityLog.svelte create mode 100644 web/src/lib/components/ConnectionStatus.svelte create mode 100644 web/src/lib/components/FailureFixDialog.svelte create mode 100644 web/src/lib/components/FailureList.svelte create mode 100644 web/src/lib/components/LocationPicker.svelte create mode 100644 web/src/lib/components/ModeSelector.svelte create mode 100644 web/src/lib/components/PendingList.svelte create mode 100644 web/src/lib/components/ScanInput.svelte create mode 100644 web/src/lib/components/SessionPanel.svelte create mode 100644 web/src/lib/components/Stat.svelte create mode 100644 web/src/lib/server/barcode.ts create mode 100644 web/src/lib/server/env.ts create mode 100644 web/src/lib/server/events.ts create mode 100644 web/src/lib/server/importQueue.ts create mode 100644 web/src/lib/server/inventree.ts create mode 100644 web/src/lib/server/sessions.ts create mode 100644 web/src/lib/stores/app.svelte.ts create mode 100644 web/src/lib/types.ts create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+page.svelte create mode 100644 web/src/routes/api/config/+server.ts create mode 100644 web/src/routes/api/events/+server.ts create mode 100644 web/src/routes/api/locations/+server.ts create mode 100644 web/src/routes/api/part/search/+server.ts create mode 100644 web/src/routes/api/proxy/image/+server.ts create mode 100644 web/src/routes/api/session/end/+server.ts create mode 100644 web/src/routes/api/session/failures/+server.ts create mode 100644 web/src/routes/api/session/location/+server.ts create mode 100644 web/src/routes/api/session/mode/+server.ts create mode 100644 web/src/routes/api/session/retry/+server.ts create mode 100644 web/src/routes/api/session/scan/+server.ts create mode 100644 web/src/routes/api/session/start/+server.ts create mode 100644 web/svelte.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 28acc61..6532693 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,16 @@ "Bash(python:*)", "Bash(git add:*)", "Bash(uv:*)", - "Bash(tree:*)" + "Bash(tree:*)", + "WebFetch(domain:git.b4l.co.th)", + "mcp__svelte__get-documentation", + "mcp__svelte__svelte-autofixer", + "Read(//c/dev/inventree-stock-tool/web/**)", + "Bash(npm install *)", + "Bash(npx svelte-kit *)", + "Bash(npx svelte-check *)", + "Bash(npm run *)", + "Bash(npx tsx *)" ], "deny": [], "ask": [] diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..232e716 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,17 @@ +# InvenTree server (no trailing slash) +INVENTREE_HOST=https://your-inventree-server.example.com +INVENTREE_TOKEN=your-api-token-here + +# Port adapter-node listens on (bound to 127.0.0.1 via HOST) +PORT=3000 +HOST=127.0.0.1 +ORIGIN=https://stock-tool.your-domain.example.com + +# Max concurrent `inventree-part-import` processes +IMPORT_CONCURRENCY=3 + +# Absolute path to inventree-part-import binary. Leave empty to resolve from PATH. +INVENTREE_PART_IMPORT_BIN= + +# Per-import timeout, seconds +IMPORT_TIMEOUT_SEC=60 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..81a9419 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,8 @@ +node_modules +.svelte-kit +build +.env +.env.* +!.env.example +.DS_Store +*.log diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..56125b9 --- /dev/null +++ b/web/README.md @@ -0,0 +1,73 @@ +# InvenTree Stock Tool — Web (SvelteKit) + +SvelteKit rewrite of the Flask/Alpine web app. + +## Stack + +- SvelteKit 2 + Svelte 5 (runes) + adapter-node +- Tailwind CSS v4 +- In-process scan-session store (no database) +- Server-Sent Events for live session/import updates +- Shell-out to `inventree-part-import` for unknown-part creation + +## Dev + +```bash +cd web +npm install +cp .env.example .env +# edit .env to point at your InvenTree server +npm run dev +``` + +Open http://localhost:5173. + +## Build + +```bash +npm run build +node build +``` + +## Feature: Scan Session + +- A session groups a run of scans. Every scan goes through `/api/session/scan` + and is recorded in the session's stats. +- Unknown parts in "Add Stock" mode are enqueued for `inventree-part-import`; + workers run in parallel (tunable via `IMPORT_CONCURRENCY`, default 3). +- Failures (unknown parts outside import mode, missing qty, invalid location, + API errors, and imports that exhaust 3 retries) land in the Failures panel. +- Each failure has a **Fix** dialog — override the part code / quantity / + location, or search for an existing part and link it instead. Retry feeds + back through the same code path. +- Export CSV of failures for offline follow-up. + +## Layout + +``` +web/ +├── src/ +│ ├── app.css, app.html, app.d.ts +│ ├── lib/ +│ │ ├── barcode.ts shared parse_scan + commands +│ │ ├── client.ts fetch wrappers for API routes +│ │ ├── types.ts shared types +│ │ ├── server/ ← imported only from +server.ts / hooks +│ │ │ ├── env.ts typed env config +│ │ │ ├── inventree.ts 8 API helpers +│ │ │ ├── events.ts SSE pubsub +│ │ │ ├── importQueue.ts N-worker pool → inventree-part-import +│ │ │ └── sessions.ts in-memory scan sessions +│ │ ├── stores/app.svelte.ts +│ │ └── components/… +│ └── routes/ +│ ├── +layout.svelte, +page.svelte +│ └── api/ +│ ├── config, locations, proxy/image, part/search, events +│ └── session/{start,end,mode,location,scan,retry,failures} +└── deploy/ systemd unit, nginx example, deploy README +``` + +## Deployment + +See [`deploy/README.md`](deploy/README.md). diff --git a/web/deploy/README.md b/web/deploy/README.md new file mode 100644 index 0000000..3127efc --- /dev/null +++ b/web/deploy/README.md @@ -0,0 +1,86 @@ +# Deployment + +Deploy target: Linux server, Node 20+, nginx fronting `adapter-node` on 127.0.0.1:3000, systemd managing the process. Python 3.10+ with `inventree-part-import` installed alongside Node — we shell out to it for unknown parts. + +## One-time setup + +```bash +sudo adduser --system --group --home /opt/inventree-stock-tool stock-tool +sudo chown -R stock-tool:stock-tool /opt/inventree-stock-tool + +# As the service user: +sudo -u stock-tool -H bash +cd /opt/inventree-stock-tool + +# Python side +python3 -m venv .venv +source .venv/bin/activate +pip install "inventree-part-import>=1.9.2" # 1.9.2+ required for InvenTree 1.x API +# configure inventree-part-import's suppliers at +# ~/.config/inventree-part-import/ (Digi-Key, Mouser, LCSC API keys) + +# Node side — pushed via git or rsync into /opt/inventree-stock-tool +npm ci --omit=dev +npm run build + +# Env +cp .env.example .env +$EDITOR .env +``` + +`.env` must contain at minimum `INVENTREE_HOST`, `INVENTREE_TOKEN`, and +`INVENTREE_PART_IMPORT_BIN=/opt/inventree-stock-tool/.venv/bin/inventree-part-import`. + +## systemd + +```bash +sudo cp deploy/inventree-stock-tool.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now inventree-stock-tool +sudo systemctl status inventree-stock-tool +journalctl -u inventree-stock-tool -f +``` + +Adjust `User=`, `Group=`, `WorkingDirectory=` in the unit file to match your +setup. The unit pins `INVENTREE_PART_IMPORT_BIN` via `.env` — the `PATH=` line +in the unit is a fallback for when the binary is on a regular `bin` directory. + +## nginx + +```bash +sudo cp deploy/nginx.conf.example /etc/nginx/sites-available/stock-tool +sudo ln -s /etc/nginx/sites-available/stock-tool /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +Then issue a cert: + +```bash +sudo certbot --nginx -d stock-tool.your-domain.example.com +``` + +## Updating + +```bash +# on build server / laptop: +rsync -az --delete --exclude=node_modules --exclude=build \ + ./web/ stock-tool@host:/opt/inventree-stock-tool/ + +# on server: +sudo -u stock-tool -H bash +cd /opt/inventree-stock-tool +npm ci --omit=dev +npm run build +exit +sudo systemctl restart inventree-stock-tool +``` + +## Tuning + +- `IMPORT_CONCURRENCY` (default 3) — number of concurrent `inventree-part-import` + subprocesses. Bump up only if supplier APIs aren't rate-limiting you and + InvenTree handles concurrent category/manufacturer writes cleanly. 3–4 is + typically the sweet spot; higher values tend to produce 429s rather than + speedups. +- `IMPORT_TIMEOUT_SEC` (default 60) — per-attempt timeout. Raise if your + supplier API is slow or the server cold-starts imports. diff --git a/web/deploy/inventree-stock-tool.service b/web/deploy/inventree-stock-tool.service new file mode 100644 index 0000000..c8efee7 --- /dev/null +++ b/web/deploy/inventree-stock-tool.service @@ -0,0 +1,32 @@ +[Unit] +Description=InvenTree Stock Tool (SvelteKit) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +WorkingDirectory=/opt/inventree-stock-tool +EnvironmentFile=/opt/inventree-stock-tool/.env +# Prepend user-site bin so `inventree-part-import` installed via `pip install --user` +# is on PATH. Adjust the path if you install it into a venv instead. +Environment=PATH=/opt/inventree-stock-tool/.local/bin:/usr/local/bin:/usr/bin:/bin +ExecStart=/usr/bin/node build +Restart=on-failure +RestartSec=3 +StandardOutput=journal +StandardError=journal + +# Basic hardening +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ReadWritePaths=/opt/inventree-stock-tool +ProtectHome=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes + +[Install] +WantedBy=multi-user.target diff --git a/web/deploy/nginx.conf.example b/web/deploy/nginx.conf.example new file mode 100644 index 0000000..fb383af --- /dev/null +++ b/web/deploy/nginx.conf.example @@ -0,0 +1,33 @@ +server { + listen 443 ssl http2; + server_name stock-tool.your-domain.example.com; + + ssl_certificate /etc/letsencrypt/live/stock-tool.your-domain.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/stock-tool.your-domain.example.com/privkey.pem; + + # SvelteKit / adapter-node backend + location / { + proxy_pass http://127.0.0.1:3000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Streaming (SSE) needs these off/long + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 24h; + proxy_send_timeout 24h; + + # WebSocket-style upgrades (not used currently, kept for future) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +server { + listen 80; + server_name stock-tool.your-domain.example.com; + return 301 https://$host$request_uri; +} diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..1c0ebaa --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2481 @@ +{ + "name": "inventree-stock-tool-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "inventree-stock-tool-web", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.11", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.1", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.10.0", + "svelte": "^5.15.0", + "svelte-check": "^4.1.1", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.2.tgz", + "integrity": "sha512-S/ggWH1LU7jTyi9DxZOKyxpVd4hF/OZ0JrEbeLjXk/DFXwRny0tjD2c992zOUYQobLrVkRVMDdmHP16HKP7GRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "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-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/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "rollup": "^4.59.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.57.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.57.1.tgz", + "integrity": "sha512-VRdSbB96cI1EnRh09CqmnQqP/YJvET5buj8S6k7CxaJqBJD4bw4fRKDjcarAj/eX9k2eHifQfDH8NtOh+ZxxPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", + "cookie": "^0.6.0", + "devalue": "^5.6.4", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", + "debug": "^4.4.1", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", + "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "svelte": "^5.0.0", + "vite": "^6.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", + "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "tailwindcss": "4.2.4" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.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/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.7.1.tgz", + "integrity": "sha512-MUbZ586EgQqdRnC4yDrlod3BEdyvE4TapGYHMW2CiaW+KkkFmWEFqBUaLltEZCGi0iFXCEjRF0OjF0DV2QHjOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.5.tgz", + "integrity": "sha512-/yLB1538mag+dn0wsePTe8C0rDIjUOaJpMs2McodSzmM2msWcZsBSdRtg6HOBt0A/r82BN+Md3pgwSc/uWt2Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "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" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.55.4", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.4.tgz", + "integrity": "sha512-q8DFohk6vUswSng95IZb9nzWJnbINZsK7OiM1snAa3qCjJBL0ZQpvMyAaVXjUukdM75J/m8UE8xwqat8Ors/zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.6.4", + "esm-env": "^1.2.1", + "esrap": "^2.2.4", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.6.tgz", + "integrity": "sha512-kP1zG81EWaFe9ZyTv4ZXv44Csi6Pkdpb7S3oj6m+K2ec/IcDg/a8LsFsnVLqm2nxtkSwsd5xPj/qFkTBgXHXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" + } + }, + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..990a823 --- /dev/null +++ b/web/package.json @@ -0,0 +1,26 @@ +{ + "name": "inventree-stock-tool-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "start": "node build", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.11", + "@sveltejs/kit": "^2.15.0", + "@sveltejs/vite-plugin-svelte": "^5.0.1", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^22.10.0", + "svelte": "^5.15.0", + "svelte-check": "^4.1.1", + "tailwindcss": "^4.0.0", + "typescript": "^5.7.2", + "vite": "^6.0.3" + } +} diff --git a/web/scripts/test-barcode.mjs b/web/scripts/test-barcode.mjs new file mode 100644 index 0000000..af98dbb --- /dev/null +++ b/web/scripts/test-barcode.mjs @@ -0,0 +1,94 @@ +import { parseScan } from '../src/lib/barcode.ts'; + +const GS = '\x1D'; +const RS = '\x1E'; + +// User's Digi-Key barcode as the scanner actually delivers it (with GS between +// fields and the RS format-envelope after `[)>`). +const digiKey = [ + '[)>', + RS, + '06', + GS, + 'P2073-USB1035-GF-P-0-B-B-ND', + GS, + '1PUSB1035-GF-P-0-B-B', + GS, + '30P2073-USB1035-GF-P-0-B-B-ND', + GS, + 'K', + GS, + '1K9867254010', + GS, + '10K1242607169', + GS, + 'D25511', + GS, + 'T251215-501', + GS, + '11K1', + GS, + '4LCN', + GS, + 'Q6', + GS, + '11ZPICK', + GS, + '12Z1064967213', + GS, + '13Z9999992', + GS, + '20Z' + '0'.repeat(55) +].join(''); + +const cases = [ + { + name: "Digi-Key MH10.8.2 with GS separators (qty=6)", + raw: digiKey, + expected: { partCode: '2073-USB1035-GF-P-0-B-B-ND', quantity: 6 } + }, + { + name: "same barcode but scanner stripped all separators (fallback)", + raw: '[)>06P2073-USB1035-GF-P-0-B-B-ND1PUSB1035-GF-P-0-B-B30P2073-USB1035-GF-P-0-B-B-NDK1K9867254010K1242607169D25511T251215-50111K14LCNQ611ZPICK12Z1064967213Z99999920Z0000000000000000000000000000000000000000000000000000000', + expected: { partCode: '2073-USB1035-GF-P-0-B-B-ND', quantity: 6 } + }, + { + name: 'GS-separated without MH10.8.2 envelope', + raw: `30PABC${GS}Q15`, + expected: { partCode: 'ABC', quantity: 15 } + }, + { + name: 'Quantity legitimately 611 before 11Z (fallback path)', + raw: '[)>06PABC1PXYZQ61111ZPICK', + expected: { partCode: 'ABC', quantity: 611 } + }, + { + name: 'MH10.8.2 with separators, only 1P present', + raw: `[)>06${GS}1PMYPART${GS}Q42`, + expected: { partCode: 'MYPART', quantity: 42 } + }, + { + name: 'JSON-like', + raw: '{PM:WIDGET-42,QTY:7}', + expected: { partCode: 'WIDGET-42', quantity: 7 } + } +]; + +let pass = 0; +let fail = 0; +for (const c of cases) { + const got = parseScan(c.raw); + const ok = + got.partCode === c.expected.partCode && got.quantity === c.expected.quantity; + if (ok) { + pass++; + console.log(`ok ${c.name}`); + } else { + fail++; + console.log(`FAIL ${c.name}`); + console.log(` got ${JSON.stringify(got)}`); + console.log(` expected ${JSON.stringify(c.expected)}`); + } +} +console.log(`\n${pass}/${pass + fail} pass`); +process.exit(fail ? 1 : 0); diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..e250c01 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +@theme { + --color-brand-500: oklch(0.55 0.15 240); + --color-brand-600: oklch(0.48 0.16 240); +} + +html, +body { + height: 100%; +} diff --git a/web/src/app.d.ts b/web/src/app.d.ts new file mode 100644 index 0000000..c380213 --- /dev/null +++ b/web/src/app.d.ts @@ -0,0 +1,12 @@ +// See https://kit.svelte.dev/docs/types#app +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..bdd4d89 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,13 @@ + + + + + + + InvenTree Stock Tool + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/barcode.ts b/web/src/lib/barcode.ts new file mode 100644 index 0000000..c388656 --- /dev/null +++ b/web/src/lib/barcode.ts @@ -0,0 +1,178 @@ +import type { ParsedBarcode } from './types'; + +const SEP_RE = /[\x1D\x1E]/g; +const SEP_RE_TEST = /[\x1D\x1E]/; + +// Matches the MH10.8.2 envelope `[)>06` with an optional RS after `[)>` and +// any trailing GS/RS separators. +const MH10_ENVELOPE = /^\[\)>\x1E?06[\x1D\x1E]*/; + +// DIs that commonly follow `Q`. Used only when the scanner stripped the GS/RS +// separators and we have to re-tokenize a continuous string. +const DIS_AFTER_Q = [ + '11Z', + '12Z', + '13Z', + '14Z', + '20Z', + '21Z', + '1Z', + '1T', + '2T', + '3T', + '1L', + '4L', + '14L' +]; + +function cleanPartCode(code: string): string { + let out = ''; + for (const ch of code) { + const c = ch.charCodeAt(0); + if (c >= 32 && c <= 126) out += ch; + } + return out.trim(); +} + +function parseJsonBraces(raw: string): ParsedBarcode { + const content = raw.trim().slice(1, -1); + let partCode: string | null = null; + let quantity: number | null = null; + for (const kv of content.split(',')) { + if (!kv.includes(':')) continue; + const [k, ...rest] = kv.split(':'); + const v = rest.join(':'); + const key = k.trim().toUpperCase(); + const val = v.trim(); + if (key === 'PM') partCode = cleanPartCode(val); + else if (key === 'QTY') { + const n = parseInt(val, 10); + if (!Number.isNaN(n)) quantity = n; + } + } + return { partCode, quantity }; +} + +// Parse fields separated by GS (0x1D) or RS (0x1E). Each field starts with a +// Data Identifier (DI) followed by its value. We care about part codes (30P +// beats 1P beats bare P) and quantity (Q). +function parseSeparatedFields(text: string): ParsedBarcode { + let pkgPart: string | null = null; // 30P — Digi-Key / container part + let mfrPart: string | null = null; // 1P — manufacturer part + let custPart: string | null = null; // P — customer part + let quantity: number | null = null; + + for (const field of text.split(SEP_RE)) { + if (!field) continue; + if (field.startsWith('30P')) pkgPart = cleanPartCode(field.substring(3)); + else if (field.startsWith('1P')) mfrPart = cleanPartCode(field.substring(2)); + else if (field[0] === 'P') custPart = cleanPartCode(field.substring(1)); + else if (field[0] === 'Q' && /^\d+$/.test(field.substring(1))) + quantity = parseInt(field.substring(1), 10); + } + + return { partCode: pkgPart ?? mfrPart ?? custPart, quantity }; +} + +// Fallback for MH10.8.2 barcodes whose GS/RS separators have been stripped. +// We can still pull out the part code by locating the next known DI, and the +// quantity by reading digits after `Q` until we hit a following DI. +function parseRunonMh10(body: string): ParsedBarcode { + let partCode: string | null = null; + let quantity: number | null = null; + + if (body.length > 0 && body[0] === 'P') { + const afterP = body.substring(1); + let endIdx = afterP.length; + for (const marker of ['1P', '30P']) { + const idx = afterP.indexOf(marker); + if (idx !== -1 && idx < endIdx) endIdx = idx; + } + partCode = cleanPartCode(afterP.substring(0, endIdx)); + } + + let from = 0; + while (from < body.length) { + const q = body.indexOf('Q', from); + if (q === -1) break; + let i = q + 1; + let digits = ''; + while (i < body.length) { + if (DIS_AFTER_Q.some((di) => body.startsWith(di, i))) break; + const ch = body.charCodeAt(i); + if (ch < 48 || ch > 57) break; + digits += body[i]; + i++; + } + if (digits) { + quantity = parseInt(digits, 10); + break; + } + from = q + 1; + } + + return { partCode, quantity }; +} + +export function parseScan(raw: string): ParsedBarcode { + if (!raw) return { partCode: null, quantity: null }; + + if (raw.startsWith('{') && raw.includes('}')) return parseJsonBraces(raw); + + if (MH10_ENVELOPE.test(raw)) { + const body = raw.replace(MH10_ENVELOPE, ''); + if (SEP_RE_TEST.test(body)) return parseSeparatedFields(body); + return parseRunonMh10(body); + } + + // Plain (possibly GS/RS-separated) — e.g. a bare `30PABC\x1DQ5`. + return parseSeparatedFields(raw); +} + +const COMMAND_TO_MODE: Record = { + 'MODE:ADD': 'import', + 'MODE:IMPORT': 'import', + ADD_STOCK: 'import', + IMPORT: 'import', + 'MODE:UPDATE': 'update', + UPDATE_STOCK: 'update', + UPDATE: 'update', + 'MODE:CHECK': 'get', + 'MODE:GET': 'get', + CHECK_STOCK: 'get', + CHECK: 'get', + 'MODE:LOCATE': 'locate', + LOCATE_PART: 'locate', + LOCATE: 'locate', + FIND_PART: 'locate' +}; + +const LOCATION_COMMANDS = new Set([ + 'CHANGE_LOCATION', + 'NEW_LOCATION', + 'SET_LOCATION', + 'LOCATION' +]); + +export type BarcodeCommand = + | { type: 'mode'; mode: 'import' | 'update' | 'get' | 'locate' } + | { type: 'change_location' } + | null; + +export function interpretCommand(code: string): BarcodeCommand { + const upper = code.toUpperCase(); + const mode = COMMAND_TO_MODE[upper]; + if (mode) return { type: 'mode', mode }; + if (LOCATION_COMMANDS.has(upper)) return { type: 'change_location' }; + return null; +} + +export function parseLocationBarcode(input: string): number | null { + const trimmed = input.trim(); + if (trimmed.toUpperCase().startsWith('INV-SL')) { + const n = parseInt(trimmed.substring(6), 10); + return Number.isNaN(n) ? null : n; + } + const n = parseInt(trimmed, 10); + return Number.isNaN(n) ? null : n; +} diff --git a/web/src/lib/client.ts b/web/src/lib/client.ts new file mode 100644 index 0000000..3b4b065 --- /dev/null +++ b/web/src/lib/client.ts @@ -0,0 +1,136 @@ +import type { FailedScan, ScanMode, ScanSession } from './types'; + +export interface RetryFixes { + partCode?: string; + quantity?: number; + locationId?: number; + partId?: number; + retryImport?: boolean; +} + +export type ScanOutcome = + | { outcome: 'ok'; message: string; detail?: Record } + | { outcome: 'failed'; failure: FailedScan } + | { outcome: 'pending_import'; partCode: string }; + +async function handle(res: Response): Promise { + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`${res.status}: ${text || res.statusText}`); + } + return (await res.json()) as T; +} + +export const api = { + async config() { + return handle<{ + host: string; + modes: Record; + barcodeCommands: Record; + importConcurrency: number; + }>(await fetch('/api/config')); + }, + + async locations() { + return handle<{ locations: Array<{ id: number; name: string; pathstring?: string }> }>( + await fetch('/api/locations') + ); + }, + + async startSession(mode: ScanMode, locationId: number | null) { + return handle<{ session: ScanSession }>( + await fetch('/api/session/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode, locationId }) + }) + ); + }, + + async endSession(sessionId: string) { + return handle<{ session: ScanSession }>( + await fetch('/api/session/end', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId }) + }) + ); + }, + + async setSessionMode(sessionId: string, mode: ScanMode) { + return handle<{ session: ScanSession }>( + await fetch('/api/session/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, mode }) + }) + ); + }, + + async setSessionLocation(sessionId: string, locationId: number | null) { + return handle<{ session: ScanSession }>( + await fetch('/api/session/location', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, locationId }) + }) + ); + }, + + async scan( + sessionId: string, + rawBarcode: string, + overrides: Partial<{ + partCode: string; + quantity: number; + locationId: number; + partId: number; + }> = {} + ) { + return handle<{ outcome: ScanOutcome; session: ScanSession }>( + await fetch('/api/session/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, rawBarcode, ...overrides }) + }) + ); + }, + + async retry(sessionId: string, failureId: string, fixes: RetryFixes) { + return handle<{ outcome: ScanOutcome; session: ScanSession }>( + await fetch('/api/session/retry', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, failureId, fixes }) + }) + ); + }, + + async dismissFailure(sessionId: string, failureId: string) { + return handle<{ session: ScanSession }>( + await fetch('/api/session/failures', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionId, failureId }) + }) + ); + } +}; + +export function failuresToCsv(failures: FailedScan[]): string { + const rows = [ + ['id', 'timestamp', 'reason', 'partCode', 'quantity', 'rawBarcode', 'message'], + ...failures.map((f) => [ + f.id, + f.timestamp, + f.reason, + f.parsedPartCode ?? '', + f.parsedQuantity ?? '', + f.rawBarcode, + f.message + ]) + ]; + return rows + .map((r) => r.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(',')) + .join('\r\n'); +} diff --git a/web/src/lib/components/ActivityLog.svelte b/web/src/lib/components/ActivityLog.svelte new file mode 100644 index 0000000..c290368 --- /dev/null +++ b/web/src/lib/components/ActivityLog.svelte @@ -0,0 +1,43 @@ + + +
+

Activity

+
+ {#each app.logs as log (log.id)} +
+ {log.time} + {log.message} +
+ {:else} +
No activity yet.
+ {/each} +
+
diff --git a/web/src/lib/components/ConnectionStatus.svelte b/web/src/lib/components/ConnectionStatus.svelte new file mode 100644 index 0000000..5843df9 --- /dev/null +++ b/web/src/lib/components/ConnectionStatus.svelte @@ -0,0 +1,24 @@ + + +
+ + + {app.connected ? 'Connected' : 'Disconnected'} + + {#if app.host} + {app.host} + {/if} +
diff --git a/web/src/lib/components/FailureFixDialog.svelte b/web/src/lib/components/FailureFixDialog.svelte new file mode 100644 index 0000000..bac5ba4 --- /dev/null +++ b/web/src/lib/components/FailureFixDialog.svelte @@ -0,0 +1,198 @@ + + +
+
+
+
+

Fix failure

+

{failure.message}

+
+ +
+ +
+
+
+ Raw: + {failure.rawBarcode} +
+
+ Reason: + {failure.reason} +
+
+
+ + +
+ + + +
+ + +
+
+ e.key === 'Enter' && runSearch()} + placeholder="Search existing parts…" + class="flex-1 rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none" + /> + +
+ {#if results.length > 0} +
    + {#each results as r (r.pk)} +
  • + +
  • + {/each} +
+ {:else if searching} +

Searching…

+ {:else} +

No suggestions yet. Enter a query above.

+ {/if} +
+ +
+ {#if failure.reason === 'import_failed' || failure.reason === 'unknown_part'} + + {/if} + +
+
+
diff --git a/web/src/lib/components/FailureList.svelte b/web/src/lib/components/FailureList.svelte new file mode 100644 index 0000000..e39dbd9 --- /dev/null +++ b/web/src/lib/components/FailureList.svelte @@ -0,0 +1,103 @@ + + +
+
+

+ Failures + {#if failures.length > 0} + {failures.length} + {/if} +

+ {#if failures.length > 0} + + {/if} +
+ + {#if failures.length === 0} +

No failures.

+ {:else} +
    + {#each failures as f (f.id)} +
  • +
    +
    +
    + + {f.parsedPartCode ?? '—'} + + {reasonLabel[f.reason] ?? f.reason} + {#if f.parsedQuantity != null} + qty {f.parsedQuantity} + {/if} +
    +
    {f.message}
    +
    + {new Date(f.timestamp).toLocaleTimeString()} +
    +
    +
    + + +
    +
    +
  • + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/LocationPicker.svelte b/web/src/lib/components/LocationPicker.svelte new file mode 100644 index 0000000..abb7072 --- /dev/null +++ b/web/src/lib/components/LocationPicker.svelte @@ -0,0 +1,66 @@ + + +
+

Location

+
+ e.key === 'Enter' && handleScan()} + placeholder="Scan location (INV-SL…) or enter ID" + class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none" + /> + +
+ {#if currentName} +

Current: {currentName}

+ {:else} +

No location selected

+ {/if} +
diff --git a/web/src/lib/components/ModeSelector.svelte b/web/src/lib/components/ModeSelector.svelte new file mode 100644 index 0000000..ce8db43 --- /dev/null +++ b/web/src/lib/components/ModeSelector.svelte @@ -0,0 +1,32 @@ + + +
+

Mode

+
+ {#each modes as mode (mode)} + {@const active = app.session?.mode === mode} + + {/each} +
+
diff --git a/web/src/lib/components/PendingList.svelte b/web/src/lib/components/PendingList.svelte new file mode 100644 index 0000000..f7ca5fa --- /dev/null +++ b/web/src/lib/components/PendingList.svelte @@ -0,0 +1,86 @@ + + +
+
+

+ Pending imports + {#if items.length > 0} + {items.length} + {/if} +

+ + up to {app.importConcurrency} in parallel + +
+ + {#if items.length === 0} +

Nothing in the queue.

+ {:else} +
    + {#each items as item (item.id)} +
  • +
    +
    +
    + + + + + {item.partCode} +
    +
    + Qty: {item.quantity ?? '—'} + Loc: {locationName(item.locationId)} + Attempt: {item.attempts}/3 + {elapsed(item.queuedAt)} +
    + {#if item.lastError} +
    + Last error: {item.lastError} +
    + {/if} +
    +
    +
  • + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/ScanInput.svelte b/web/src/lib/components/ScanInput.svelte new file mode 100644 index 0000000..fa7dec5 --- /dev/null +++ b/web/src/lib/components/ScanInput.svelte @@ -0,0 +1,46 @@ + + +
+

Scan part

+
+ e.key === 'Enter' && submit()} + {disabled} + placeholder="Scan barcode or type part code…" + class="flex-1 rounded-md border border-slate-300 bg-white px-4 py-3 text-lg shadow-sm focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:outline-none disabled:opacity-50" + /> + +
+
diff --git a/web/src/lib/components/SessionPanel.svelte b/web/src/lib/components/SessionPanel.svelte new file mode 100644 index 0000000..d2ecf1b --- /dev/null +++ b/web/src/lib/components/SessionPanel.svelte @@ -0,0 +1,62 @@ + + +
+
+
+

Scan session

+ {#if session} +

+ {active ? 'Active' : 'Ended'} — started + {new Date(session.startedAt).toLocaleTimeString()} + {#if session.endedAt} + , ended {new Date(session.endedAt).toLocaleTimeString()} + {/if} +

+ {:else} +

No session yet

+ {/if} +
+
+ {#if active} + + {:else} + + {/if} +
+
+ + {#if session} +
+ + + + +
+ {/if} +
diff --git a/web/src/lib/components/Stat.svelte b/web/src/lib/components/Stat.svelte new file mode 100644 index 0000000..e3da90b --- /dev/null +++ b/web/src/lib/components/Stat.svelte @@ -0,0 +1,23 @@ + + +
+
{label}
+
{value}
+
diff --git a/web/src/lib/server/barcode.ts b/web/src/lib/server/barcode.ts new file mode 100644 index 0000000..b9eb168 --- /dev/null +++ b/web/src/lib/server/barcode.ts @@ -0,0 +1,2 @@ +export { parseScan, interpretCommand, parseLocationBarcode } from '$lib/barcode'; +export type { BarcodeCommand } from '$lib/barcode'; diff --git a/web/src/lib/server/env.ts b/web/src/lib/server/env.ts new file mode 100644 index 0000000..b3af918 --- /dev/null +++ b/web/src/lib/server/env.ts @@ -0,0 +1,54 @@ +import { env } from '$env/dynamic/private'; + +interface Config { + host: string; + token: string; + importConcurrency: number; + importTimeoutMs: number; + importBin: string; +} + +function int(value: string | undefined, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback; +} + +let cached: Config | null = null; + +function compute(): Config { + const host = env.INVENTREE_HOST; + const token = env.INVENTREE_TOKEN; + if (!host) throw new Error('Missing required env var: INVENTREE_HOST'); + if (!token) throw new Error('Missing required env var: INVENTREE_TOKEN'); + return { + host: host.replace(/\/$/, ''), + token, + importConcurrency: int(env.IMPORT_CONCURRENCY, 3), + importTimeoutMs: int(env.IMPORT_TIMEOUT_SEC, 60) * 1000, + importBin: env.INVENTREE_PART_IMPORT_BIN || 'inventree-part-import' + }; +} + +// Lazy proxy so importing this module never touches env; the check runs on +// first property access (inside a request handler, not during build analysis). +export const config = new Proxy({} as Config, { + get(_target, prop: keyof Config) { + cached ??= compute(); + return cached[prop]; + } +}); + +export const authHeaders = new Proxy({} as Record, { + get(_target, prop: string) { + cached ??= compute(); + if (prop === 'Authorization') return `Token ${cached.token}`; + if (prop === 'Content-Type') return 'application/json'; + return undefined; + }, + ownKeys() { + return ['Authorization', 'Content-Type']; + }, + getOwnPropertyDescriptor() { + return { enumerable: true, configurable: true }; + } +}); diff --git a/web/src/lib/server/events.ts b/web/src/lib/server/events.ts new file mode 100644 index 0000000..67ff154 --- /dev/null +++ b/web/src/lib/server/events.ts @@ -0,0 +1,44 @@ +import { EventEmitter } from 'node:events'; +import type { ServerEvent } from '$lib/types'; + +class Bus extends EventEmitter { + publish(event: ServerEvent): void { + this.emit('event', event); + } +} + +export const bus = new Bus(); +bus.setMaxListeners(100); + +export function subscribe(listener: (e: ServerEvent) => void): () => void { + bus.on('event', listener); + return () => bus.off('event', listener); +} + +export function sseStream(): ReadableStream { + const encoder = new TextEncoder(); + let unsubscribe: (() => void) | null = null; + let heartbeat: ReturnType | null = null; + + return new ReadableStream({ + start(controller) { + const send = (event: ServerEvent) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + // stream closed + } + }; + + send({ type: 'heartbeat', at: new Date().toISOString() }); + unsubscribe = subscribe(send); + heartbeat = setInterval(() => { + send({ type: 'heartbeat', at: new Date().toISOString() }); + }, 20_000); + }, + cancel() { + unsubscribe?.(); + if (heartbeat) clearInterval(heartbeat); + } + }); +} diff --git a/web/src/lib/server/importQueue.ts b/web/src/lib/server/importQueue.ts new file mode 100644 index 0000000..c668708 --- /dev/null +++ b/web/src/lib/server/importQueue.ts @@ -0,0 +1,194 @@ +import { spawn } from 'node:child_process'; +import { config } from './env'; +import { bus } from './events'; +import { findPart } from './inventree'; + +export interface ImportResult { + partCode: string; + success: boolean; + partId: number | null; + error: string | null; +} + +type Resolver = (r: ImportResult) => void; + +interface QueueItem { + partCode: string; + attempts: number; + sessionId: string | null; + resolvers: Resolver[]; +} + +const MAX_ATTEMPTS = 3; + +class ImportQueue { + private queue: QueueItem[] = []; + private pending = new Map(); // partCode → item (dedupes) + private activeWorkers = 0; + private started = false; + + enqueue( + partCode: string, + opts: { sessionId?: string | null } = {} + ): Promise { + this.start(); + const existing = this.pending.get(partCode); + if (existing) { + return new Promise((resolve) => existing.resolvers.push(resolve)); + } + const item: QueueItem = { + partCode, + attempts: 0, + sessionId: opts.sessionId ?? null, + resolvers: [] + }; + this.pending.set(partCode, item); + this.queue.push(item); + bus.publish({ type: 'import_queued', partCode, sessionId: item.sessionId }); + const promise = new Promise((resolve) => item.resolvers.push(resolve)); + this.pump(); + return promise; + } + + private start(): void { + this.started = true; + } + + private pump(): void { + while (this.activeWorkers < config.importConcurrency && this.queue.length > 0) { + const item = this.queue.shift(); + if (!item) break; + this.activeWorkers++; + this.work(item).finally(() => { + this.activeWorkers--; + this.pump(); + }); + } + } + + private async work(item: QueueItem): Promise { + item.attempts++; + const attemptResult = await this.runImport(item.partCode); + + if (attemptResult.success && attemptResult.partId !== null) { + this.pending.delete(item.partCode); + bus.publish({ + type: 'import_complete', + partCode: item.partCode, + partId: attemptResult.partId, + sessionId: item.sessionId + }); + for (const r of item.resolvers) r(attemptResult); + return; + } + + if (item.attempts < MAX_ATTEMPTS) { + bus.publish({ + type: 'import_retry', + partCode: item.partCode, + attempts: item.attempts, + error: attemptResult.error ?? 'unknown error', + sessionId: item.sessionId + }); + this.queue.push(item); + return; + } + + this.pending.delete(item.partCode); + bus.publish({ + type: 'import_failed', + partCode: item.partCode, + error: attemptResult.error ?? 'unknown error', + sessionId: item.sessionId + }); + for (const r of item.resolvers) r(attemptResult); + } + + private async runImport(partCode: string): Promise { + const spawnResult = await spawnImport(partCode); + + // Always probe the server — `inventree-part-import` can create the part + // and then fail late on a secondary step (e.g. a duplicate parameter + // template). If the part is there, the import is usable regardless of + // the subprocess exit code. + let partId: number | null = null; + let lookupError: string | null = null; + try { + partId = await findPart(partCode); + } catch (e) { + lookupError = e instanceof Error ? e.message : String(e); + } + + if (partId !== null) { + return { partCode, success: true, partId, error: null }; + } + + if (!spawnResult.ok) { + return { partCode, success: false, partId: null, error: spawnResult.error }; + } + + return { + partCode, + success: false, + partId: null, + error: lookupError ?? 'Imported but part not found on server' + }; + } + + isStarted(): boolean { + return this.started; + } + + stats() { + return { + queued: this.queue.length, + inFlight: this.activeWorkers, + pending: this.pending.size + }; + } +} + +async function spawnImport( + partCode: string +): Promise<{ ok: true } | { ok: false; error: string }> { + return await new Promise((resolve) => { + const child = spawn(config.importBin, [partCode], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stderr = ''; + let settled = false; + + const finish = (result: { ok: true } | { ok: false; error: string }) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + + const timer = setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + // ignore + } + finish({ ok: false, error: `Import timeout (${config.importTimeoutMs / 1000}s)` }); + }, config.importTimeoutMs); + + child.stderr?.on('data', (d) => { + stderr += d.toString(); + }); + child.on('error', (err) => { + finish({ ok: false, error: `Failed to spawn ${config.importBin}: ${err.message}` }); + }); + child.on('close', (code) => { + if (code === 0) finish({ ok: true }); + else + finish({ + ok: false, + error: `Import exited ${code}: ${stderr.trim().slice(-300) || 'no stderr'}` + }); + }); + }); +} + +export const importQueue = new ImportQueue(); diff --git a/web/src/lib/server/inventree.ts b/web/src/lib/server/inventree.ts new file mode 100644 index 0000000..4527370 --- /dev/null +++ b/web/src/lib/server/inventree.ts @@ -0,0 +1,195 @@ +import { authHeaders, config } from './env'; +import type { Location, PartInfo, PartParameter, StockItem } from '$lib/types'; + +async function req( + method: string, + path: string, + { + params, + body, + headers + }: { params?: Record; body?: unknown; headers?: Record } = {} +): Promise { + const url = new URL(config.host + path); + if (params) { + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v)); + } + const res = await fetch(url, { + method, + headers: { ...authHeaders, ...(headers ?? {}) }, + body: body === undefined ? undefined : JSON.stringify(body) + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`InvenTree ${method} ${path} → ${res.status}: ${text.slice(0, 300)}`); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +function unwrapResults(data: unknown): T[] { + if (Array.isArray(data)) return data as T[]; + if (data && typeof data === 'object' && 'results' in data) { + const r = (data as { results: unknown }).results; + return Array.isArray(r) ? (r as T[]) : []; + } + return []; +} + +export async function getLocations(): Promise { + const data = await req('GET', '/api/stock/location/'); + return unwrapResults<{ id?: number; pk?: number; name?: string; pathstring?: string }>(data).map( + (loc) => ({ + id: (loc.id ?? loc.pk) as number, + name: loc.name ?? '', + pathstring: loc.pathstring + }) + ); +} + +export async function getLocationDetails(id: number): Promise { + const data = await req<{ id?: number; pk?: number; name?: string; pathstring?: string }>( + 'GET', + `/api/stock/location/${id}/` + ); + return { + id: (data.id ?? data.pk) as number, + name: data.name ?? '', + pathstring: data.pathstring + }; +} + +export async function findPart(partCode: string): Promise { + const data = await req('GET', '/api/part/', { params: { search: partCode } }); + const results = unwrapResults<{ pk?: number; id?: number }>(data); + if (!results.length) return null; + return (results[0].pk ?? results[0].id) ?? null; +} + +export async function searchParts( + query: string, + limit = 10 +): Promise> { + const data = await req('GET', '/api/part/', { + params: { search: query, limit } + }); + return unwrapResults<{ pk?: number; id?: number; IPN?: string | null; name?: string }>(data).map( + (p) => ({ + pk: (p.pk ?? p.id) as number, + IPN: p.IPN ?? null, + name: p.name ?? '' + }) + ); +} + +export async function getPartInfo(partId: number): Promise { + return req('GET', `/api/part/${partId}/`); +} + +export async function getPartParameters(partId: number): Promise { + const data = await req('GET', '/api/part/parameter/', { + params: { part: partId } + }); + return unwrapResults(data); +} + +export async function getPartStock(partId: number): Promise { + const data = await req('GET', '/api/stock/', { params: { part: partId } }); + return unwrapResults(data); +} + +export async function getPartLocationsSummary(partId: number): Promise<{ + locations: Array<{ + locationId: number; + name: string; + path: string; + quantity: number; + }>; + total: number; +}> { + const items = await getPartStock(partId); + let total = 0; + const out = [] as Array<{ + locationId: number; + name: string; + path: string; + quantity: number; + }>; + for (const item of items) { + const qty = Number(item.quantity) || 0; + total += qty; + if (item.location == null) continue; + try { + const loc = await getLocationDetails(item.location); + out.push({ + locationId: item.location, + name: loc.name, + path: loc.pathstring ?? '', + quantity: qty + }); + } catch { + out.push({ + locationId: item.location, + name: `Location ${item.location}`, + path: '', + quantity: qty + }); + } + } + return { locations: out, total }; +} + +export async function findStockItem( + partId: number, + locationId: number +): Promise { + const data = await req('GET', '/api/stock/', { + params: { part: partId, location: locationId } + }); + const results = unwrapResults(data); + return results[0] ?? null; +} + +export async function getStockLevel(partId: number, locationId: number): Promise { + const item = await findStockItem(partId, locationId); + return item ? Number(item.quantity) : 0; +} + +export async function createStockItem( + partId: number, + locationId: number, + quantity: number +): Promise { + const resp = await req('POST', '/api/stock/', { + body: { part: partId, location: locationId, quantity } + }); + const item = Array.isArray(resp) ? resp[0] : resp; + return item as StockItem; +} + +export async function patchStockQuantity(stockItemId: number, quantity: number): Promise { + await req('PATCH', `/api/stock/${stockItemId}/`, { body: { quantity } }); +} + +export async function fetchImage( + url: string +): Promise<{ body: ArrayBuffer; contentType: string }> { + const absolute = url.startsWith('/') ? config.host + url : url; + const res = await fetch(absolute, { + headers: { Authorization: `Token ${config.token}` } + }); + if (!res.ok) throw new Error(`Image fetch failed: ${res.status}`); + return { + body: await res.arrayBuffer(), + contentType: res.headers.get('content-type') ?? 'image/jpeg' + }; +} + +export async function ping(): Promise { + try { + const res = await fetch(config.host + '/api/', { headers: authHeaders }); + return res.ok; + } catch { + return false; + } +} diff --git a/web/src/lib/server/sessions.ts b/web/src/lib/server/sessions.ts new file mode 100644 index 0000000..44ed0b3 --- /dev/null +++ b/web/src/lib/server/sessions.ts @@ -0,0 +1,459 @@ +import { randomUUID } from 'node:crypto'; +import type { + FailedScan, + FailureReason, + ScanMode, + ScanSession, + ServerEvent +} from '$lib/types'; +import { bus, subscribe } from './events'; +import { importQueue } from './importQueue'; +import { parseScan } from './barcode'; +import { + createStockItem, + findPart, + findStockItem, + getPartLocationsSummary, + getStockLevel, + patchStockQuantity, + searchParts +} from './inventree'; + +export interface ScanInput { + rawBarcode: string; + // overrides (used by retry flow) + partCode?: string | null; + quantity?: number | null; + locationId?: number | null; + partId?: number | null; +} + +export type ScanOutcome = + | { outcome: 'ok'; message: string; detail?: Record } + | { outcome: 'failed'; failure: FailedScan } + | { outcome: 'pending_import'; partCode: string }; + +interface InternalSession extends ScanSession { + // track pending imports for retry continuation +} + +const sessions = new Map(); + +function emitUpdate(session: InternalSession): void { + bus.publish({ type: 'session_update', session: clone(session) }); +} + +function clone(session: InternalSession): ScanSession { + return JSON.parse(JSON.stringify(session)); +} + +export function createSession(input: { + mode: ScanMode; + locationId: number | null; +}): ScanSession { + const session: InternalSession = { + id: randomUUID(), + startedAt: new Date().toISOString(), + endedAt: null, + mode: input.mode, + locationId: input.locationId, + stats: { scanned: 0, succeeded: 0, failed: 0, pending: 0 }, + failures: [], + pendingImports: [] + }; + sessions.set(session.id, session); + emitUpdate(session); + return clone(session); +} + +export function endSession(id: string): ScanSession | null { + const s = sessions.get(id); + if (!s) return null; + s.endedAt = new Date().toISOString(); + emitUpdate(s); + return clone(s); +} + +export function getSession(id: string): ScanSession | null { + const s = sessions.get(id); + return s ? clone(s) : null; +} + +export function listSessions(): ScanSession[] { + return [...sessions.values()].map(clone); +} + +export function updateSessionMode(id: string, mode: ScanMode): ScanSession | null { + const s = sessions.get(id); + if (!s) return null; + s.mode = mode; + emitUpdate(s); + return clone(s); +} + +export function updateSessionLocation( + id: string, + locationId: number | null +): ScanSession | null { + const s = sessions.get(id); + if (!s) return null; + s.locationId = locationId; + emitUpdate(s); + return clone(s); +} + +function recordFailure( + session: InternalSession, + data: { + reason: FailureReason; + message: string; + rawBarcode: string; + parsedPartCode: string | null; + parsedQuantity: number | null; + suggestions?: FailedScan['suggestions']; + } +): FailedScan { + const failure: FailedScan = { + id: randomUUID(), + rawBarcode: data.rawBarcode, + parsedPartCode: data.parsedPartCode, + parsedQuantity: data.parsedQuantity, + reason: data.reason, + message: data.message, + timestamp: new Date().toISOString(), + attempts: 1, + suggestions: data.suggestions + }; + session.failures.push(failure); + session.stats.failed++; + emitUpdate(session); + return failure; +} + +function markSucceeded(session: InternalSession): void { + session.stats.succeeded++; + emitUpdate(session); +} + +export function dismissFailure(sessionId: string, failureId: string): ScanSession | null { + const s = sessions.get(sessionId); + if (!s) return null; + const before = s.failures.length; + s.failures = s.failures.filter((f) => f.id !== failureId); + if (s.failures.length < before) { + s.stats.failed = Math.max(0, s.stats.failed - 1); + emitUpdate(s); + } + return clone(s); +} + +async function executeMode( + mode: ScanMode, + partId: number, + locationId: number | null, + quantity: number | null +): Promise<{ message: string; detail?: Record }> { + if (mode === 'locate') { + const summary = await getPartLocationsSummary(partId); + return { + message: `Found in ${summary.locations.length} location(s); total ${summary.total}`, + detail: summary as unknown as Record + }; + } + + if (mode === 'get') { + if (locationId === null) throw new Error('invalid_location'); + const qty = await getStockLevel(partId, locationId); + return { message: `Current stock: ${qty}`, detail: { quantity: qty } }; + } + + if (locationId === null) throw new Error('invalid_location'); + if (quantity === null || Number.isNaN(quantity)) throw new Error('missing_quantity'); + + if (mode === 'import') { + const existing = await findStockItem(partId, locationId); + if (existing) { + const current = Number(existing.quantity); + const next = current + quantity; + await patchStockQuantity(existing.pk, next); + return { + message: `Added ${quantity} (${current} → ${next})`, + detail: { previous: current, next, stockItemId: existing.pk } + }; + } + const created = await createStockItem(partId, locationId, quantity); + return { message: `Created stock item with ${quantity}`, detail: { stockItemId: created.pk } }; + } + + // mode === 'update' + const existing = await findStockItem(partId, locationId); + if (existing) { + const current = Number(existing.quantity); + await patchStockQuantity(existing.pk, quantity); + return { + message: `Updated stock ${current} → ${quantity}`, + detail: { previous: current, next: quantity, stockItemId: existing.pk } + }; + } + const created = await createStockItem(partId, locationId, quantity); + return { message: `Created stock item with ${quantity}`, detail: { stockItemId: created.pk } }; +} + +async function suggestParts( + partCode: string +): Promise { + try { + const hits = await searchParts(partCode, 5); + return hits; + } catch { + return []; + } +} + +export async function processScan( + sessionId: string, + input: ScanInput +): Promise { + const session = sessions.get(sessionId); + if (!session) throw new Error(`Unknown session ${sessionId}`); + if (session.endedAt) throw new Error('Session already ended'); + + session.stats.scanned++; + + const parsed = parseScan(input.rawBarcode); + const partCode = input.partCode ?? parsed.partCode; + const quantity = input.quantity ?? parsed.quantity; + const locationId = input.locationId ?? session.locationId; + const mode = session.mode; + + if (!partCode && input.partId == null) { + const failure = recordFailure(session, { + reason: 'parse_failed', + message: `Could not parse part code from barcode: ${truncate(input.rawBarcode)}`, + rawBarcode: input.rawBarcode, + parsedPartCode: null, + parsedQuantity: quantity + }); + return { outcome: 'failed', failure }; + } + + let partId: number | null = input.partId ?? null; + if (partId == null && partCode) { + try { + partId = await findPart(partCode); + } catch (e) { + const failure = recordFailure(session, { + reason: 'api_error', + message: e instanceof Error ? e.message : String(e), + rawBarcode: input.rawBarcode, + parsedPartCode: partCode, + parsedQuantity: quantity + }); + return { outcome: 'failed', failure }; + } + } + + if (partId == null) { + if (mode === 'import' && partCode) { + enqueueImportAndContinue(session, { + rawBarcode: input.rawBarcode, + partCode, + quantity, + locationId + }); + return { outcome: 'pending_import', partCode }; + } + + const suggestions = partCode ? await suggestParts(partCode) : []; + const failure = recordFailure(session, { + reason: 'unknown_part', + message: `Part "${partCode}" not found`, + rawBarcode: input.rawBarcode, + parsedPartCode: partCode, + parsedQuantity: quantity, + suggestions + }); + return { outcome: 'failed', failure }; + } + + try { + const result = await executeMode(mode, partId, locationId, quantity); + markSucceeded(session); + return { outcome: 'ok', message: result.message, detail: result.detail }; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const reason: FailureReason = + msg === 'missing_quantity' + ? 'missing_quantity' + : msg === 'invalid_location' + ? 'invalid_location' + : 'api_error'; + const failure = recordFailure(session, { + reason, + message: reason === 'api_error' ? msg : humanReason(reason), + rawBarcode: input.rawBarcode, + parsedPartCode: partCode, + parsedQuantity: quantity + }); + return { outcome: 'failed', failure }; + } +} + +function syncPendingCount(session: InternalSession): void { + session.stats.pending = session.pendingImports.length; +} + +function removePending(session: InternalSession, partCode: string): void { + session.pendingImports = session.pendingImports.filter((p) => p.partCode !== partCode); + syncPendingCount(session); +} + +function enqueueImportAndContinue( + session: InternalSession, + ctx: { + rawBarcode: string; + partCode: string; + quantity: number | null; + locationId: number | null; + } +): void { + // Deduplicate: if the same part is already being imported within this session, + // the worker-pool queue will merge our attempt with the in-flight one. Just + // refresh the visible entry's metadata and return without double-counting. + const existing = session.pendingImports.find((p) => p.partCode === ctx.partCode); + if (existing) { + existing.quantity = ctx.quantity; + existing.locationId = ctx.locationId; + emitUpdate(session); + return; + } + + session.pendingImports.push({ + id: randomUUID(), + partCode: ctx.partCode, + queuedAt: new Date().toISOString(), + attempts: 1, + quantity: ctx.quantity, + locationId: ctx.locationId, + mode: session.mode, + lastError: null + }); + syncPendingCount(session); + emitUpdate(session); + + void (async () => { + const result = await importQueue.enqueue(ctx.partCode, { sessionId: session.id }); + removePending(session, ctx.partCode); + + if (result.success && result.partId !== null) { + try { + await executeMode(session.mode, result.partId, ctx.locationId, ctx.quantity); + markSucceeded(session); + return; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const reason: FailureReason = + msg === 'missing_quantity' + ? 'missing_quantity' + : msg === 'invalid_location' + ? 'invalid_location' + : 'api_error'; + recordFailure(session, { + reason, + message: reason === 'api_error' ? msg : humanReason(reason), + rawBarcode: ctx.rawBarcode, + parsedPartCode: ctx.partCode, + parsedQuantity: ctx.quantity + }); + return; + } + } + + recordFailure(session, { + reason: 'import_failed', + message: result.error ?? 'Import failed after retries', + rawBarcode: ctx.rawBarcode, + parsedPartCode: ctx.partCode, + parsedQuantity: ctx.quantity + }); + })(); +} + +// Reflect retry progress on the visible pending list as the worker pool +// surfaces `import_retry` events from the bus. +subscribe((event) => { + if (event.type !== 'import_retry') return; + if (!event.sessionId) return; + const session = sessions.get(event.sessionId); + if (!session) return; + const entry = session.pendingImports.find((p) => p.partCode === event.partCode); + if (!entry) return; + entry.attempts = event.attempts + 1; + entry.lastError = event.error; + emitUpdate(session); +}); + +export interface RetryFixes { + partCode?: string; + quantity?: number; + locationId?: number; + partId?: number; // user picked an existing part + retryImport?: boolean; +} + +export async function retryFailure( + sessionId: string, + failureId: string, + fixes: RetryFixes +): Promise { + const session = sessions.get(sessionId); + if (!session) throw new Error(`Unknown session ${sessionId}`); + const failure = session.failures.find((f) => f.id === failureId); + if (!failure) throw new Error(`Unknown failure ${failureId}`); + + failure.attempts++; + + // decrement failed stat; if the retry fails we'll re-record + session.stats.failed = Math.max(0, session.stats.failed - 1); + session.failures = session.failures.filter((f) => f.id !== failureId); + // don't emit yet — processScan will emit + session.stats.scanned--; // processScan will ++ again + + const input: ScanInput = { + rawBarcode: failure.rawBarcode, + partCode: fixes.partCode ?? failure.parsedPartCode, + quantity: fixes.quantity ?? failure.parsedQuantity, + locationId: fixes.locationId ?? session.locationId + }; + + if (fixes.partId != null) input.partId = fixes.partId; + if (fixes.retryImport && input.partCode) { + session.stats.scanned++; + enqueueImportAndContinue(session, { + rawBarcode: failure.rawBarcode, + partCode: input.partCode, + quantity: input.quantity ?? null, + locationId: input.locationId ?? null + }); + return { outcome: 'pending_import', partCode: input.partCode }; + } + + return processScan(sessionId, input); +} + +function truncate(s: string, n = 60): string { + return s.length <= n ? s : s.slice(0, n) + '…'; +} + +function humanReason(r: FailureReason): string { + switch (r) { + case 'missing_quantity': + return 'No quantity found in scan'; + case 'invalid_location': + return 'No location selected for this operation'; + default: + return r; + } +} + +export type { ServerEvent }; diff --git a/web/src/lib/stores/app.svelte.ts b/web/src/lib/stores/app.svelte.ts new file mode 100644 index 0000000..cabf026 --- /dev/null +++ b/web/src/lib/stores/app.svelte.ts @@ -0,0 +1,72 @@ +import type { + Location, + ScanMode, + ScanSession, + ServerEvent +} from '$lib/types'; + +interface LogEntry { + id: number; + time: string; + type: 'info' | 'success' | 'warning' | 'error'; + message: string; +} + +class AppState { + host = $state(''); + modes: Record = $state({}); + importConcurrency = $state(3); + + locations = $state([]); + session = $state(null); + connected = $state(false); + lastScanAt = $state(null); + + logs = $state([]); + private nextLogId = 1; + private maxLogs = 200; + + log(type: LogEntry['type'], message: string): void { + const entry: LogEntry = { + id: this.nextLogId++, + time: new Date().toLocaleTimeString(), + type, + message + }; + this.logs = [...this.logs, entry].slice(-this.maxLogs); + } + + setSession(session: ScanSession | null): void { + this.session = session; + } + + handleServerEvent(event: ServerEvent): void { + switch (event.type) { + case 'heartbeat': + this.connected = true; + break; + case 'session_update': + if (this.session && event.session.id === this.session.id) { + this.session = event.session; + } + break; + case 'import_queued': + this.log('info', `Queued for import: ${event.partCode}`); + break; + case 'import_complete': + this.log('success', `Imported: ${event.partCode}`); + break; + case 'import_retry': + this.log( + 'warning', + `Import retry ${event.attempts}/3: ${event.partCode} — ${event.error}` + ); + break; + case 'import_failed': + this.log('error', `Import failed: ${event.partCode} — ${event.error}`); + break; + } + } +} + +export const app = new AppState(); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000..aeb0f37 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,131 @@ +export type ScanMode = 'import' | 'update' | 'get' | 'locate'; + +export const MODE_NAMES: Record = { + import: 'Add Stock', + update: 'Update Stock', + get: 'Check Stock', + locate: 'Locate Part' +}; + +export const BARCODE_COMMANDS = { + import: ['MODE:ADD', 'MODE:IMPORT', 'ADD_STOCK', 'IMPORT'], + update: ['MODE:UPDATE', 'UPDATE_STOCK', 'UPDATE'], + get: ['MODE:CHECK', 'MODE:GET', 'CHECK_STOCK', 'CHECK'], + locate: ['MODE:LOCATE', 'LOCATE_PART', 'LOCATE', 'FIND_PART'], + debug_on: ['DEBUG:ON', 'DEBUG_ON'], + debug_off: ['DEBUG:OFF', 'DEBUG_OFF'], + change_location: ['CHANGE_LOCATION', 'NEW_LOCATION', 'SET_LOCATION', 'LOCATION'] +} as const; + +export interface Location { + id: number; + name: string; + pathstring?: string; +} + +export interface PartInfo { + pk: number; + IPN: string | null; + name: string; + description: string | null; + image: string | null; + thumbnail: string | null; + category: number | null; + [key: string]: unknown; +} + +export interface PartParameter { + pk: number; + data: string | null; + template_detail?: { name: string; units?: string }; +} + +export interface StockItem { + pk: number; + part: number; + location: number | null; + quantity: number; +} + +export interface PartLocation { + location_id: number; + location_name: string; + location_path: string; + quantity: number; +} + +export type FailureReason = + | 'unknown_part' + | 'import_failed' + | 'parse_failed' + | 'missing_quantity' + | 'invalid_location' + | 'api_error'; + +export interface FailedScan { + id: string; + rawBarcode: string; + parsedPartCode: string | null; + parsedQuantity: number | null; + reason: FailureReason; + message: string; + timestamp: string; + attempts: number; + suggestions?: Array<{ pk: number; IPN: string | null; name: string }>; +} + +export interface PendingImport { + id: string; + partCode: string; + queuedAt: string; + attempts: number; + quantity: number | null; + locationId: number | null; + mode: ScanMode; + lastError: string | null; +} + +export interface ScanSession { + id: string; + startedAt: string; + endedAt: string | null; + mode: ScanMode; + locationId: number | null; + stats: { + scanned: number; + succeeded: number; + failed: number; + pending: number; + }; + failures: FailedScan[]; + pendingImports: PendingImport[]; +} + +export interface ParsedBarcode { + partCode: string | null; + quantity: number | null; +} + +export type ServerEvent = + | { type: 'heartbeat'; at: string } + | { type: 'session_update'; session: ScanSession } + | { type: 'import_queued'; partCode: string; sessionId: string | null } + | { + type: 'import_complete'; + partCode: string; + partId: number; + sessionId: string | null; + } + | { + type: 'import_retry'; + partCode: string; + attempts: number; + error: string; + sessionId: string | null; + } + | { + type: 'import_failed'; + partCode: string; + error: string; + sessionId: string | null; + }; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..b93e9ba --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..23fbeaf --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,217 @@ + + +
+
+
+

InvenTree Stock Tool

+ +
+
+ +
+ + + {#if awaitingLocationChange} +
+ Waiting for next scan to be interpreted as a location… + +
+ {/if} + +
+ +
+ + + + +
+ + +
+ + +
+
+
+ + {#if fixing} + {#key fixing.id} + (fixing = null)} + onSubmit={submitFix} + /> + {/key} + {/if} +
diff --git a/web/src/routes/api/config/+server.ts b/web/src/routes/api/config/+server.ts new file mode 100644 index 0000000..d0f0ef4 --- /dev/null +++ b/web/src/routes/api/config/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { config } from '$lib/server/env'; +import { BARCODE_COMMANDS, MODE_NAMES } from '$lib/types'; + +export const GET: RequestHandler = () => { + return json({ + host: config.host, + modes: MODE_NAMES, + barcodeCommands: BARCODE_COMMANDS, + importConcurrency: config.importConcurrency + }); +}; diff --git a/web/src/routes/api/events/+server.ts b/web/src/routes/api/events/+server.ts new file mode 100644 index 0000000..dd7ec9e --- /dev/null +++ b/web/src/routes/api/events/+server.ts @@ -0,0 +1,13 @@ +import type { RequestHandler } from './$types'; +import { sseStream } from '$lib/server/events'; + +export const GET: RequestHandler = () => { + return new Response(sseStream(), { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no' + } + }); +}; diff --git a/web/src/routes/api/locations/+server.ts b/web/src/routes/api/locations/+server.ts new file mode 100644 index 0000000..f5e756f --- /dev/null +++ b/web/src/routes/api/locations/+server.ts @@ -0,0 +1,13 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getLocations } from '$lib/server/inventree'; + +export const GET: RequestHandler = async () => { + try { + const locations = await getLocations(); + return json({ locations }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + error(502, msg); + } +}; diff --git a/web/src/routes/api/part/search/+server.ts b/web/src/routes/api/part/search/+server.ts new file mode 100644 index 0000000..8594914 --- /dev/null +++ b/web/src/routes/api/part/search/+server.ts @@ -0,0 +1,19 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { searchParts } from '$lib/server/inventree'; + +export const POST: RequestHandler = async ({ request }) => { + const { query, limit } = (await request.json().catch(() => ({}))) as { + query?: string; + limit?: number; + }; + if (!query) error(400, 'query required'); + + try { + const results = await searchParts(query, limit ?? 10); + return json({ results }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + error(502, msg); + } +}; diff --git a/web/src/routes/api/proxy/image/+server.ts b/web/src/routes/api/proxy/image/+server.ts new file mode 100644 index 0000000..b33e7c1 --- /dev/null +++ b/web/src/routes/api/proxy/image/+server.ts @@ -0,0 +1,21 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { fetchImage } from '$lib/server/inventree'; + +export const GET: RequestHandler = async ({ url }) => { + const target = url.searchParams.get('url'); + if (!target) error(400, 'url parameter required'); + + try { + const { body, contentType } = await fetchImage(target); + return new Response(body, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'private, max-age=300' + } + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + error(502, msg); + } +}; diff --git a/web/src/routes/api/session/end/+server.ts b/web/src/routes/api/session/end/+server.ts new file mode 100644 index 0000000..a8eb2e7 --- /dev/null +++ b/web/src/routes/api/session/end/+server.ts @@ -0,0 +1,11 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { endSession } from '$lib/server/sessions'; + +export const POST: RequestHandler = async ({ request }) => { + const { sessionId } = (await request.json().catch(() => ({}))) as { sessionId?: string }; + if (!sessionId) error(400, 'sessionId required'); + const session = endSession(sessionId); + if (!session) error(404, 'session not found'); + return json({ session }); +}; diff --git a/web/src/routes/api/session/failures/+server.ts b/web/src/routes/api/session/failures/+server.ts new file mode 100644 index 0000000..5d68841 --- /dev/null +++ b/web/src/routes/api/session/failures/+server.ts @@ -0,0 +1,22 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { dismissFailure, getSession } from '$lib/server/sessions'; + +export const GET: RequestHandler = ({ url }) => { + const sessionId = url.searchParams.get('sessionId'); + if (!sessionId) error(400, 'sessionId required'); + const session = getSession(sessionId); + if (!session) error(404, 'session not found'); + return json({ failures: session.failures }); +}; + +export const DELETE: RequestHandler = async ({ request }) => { + const { sessionId, failureId } = (await request.json().catch(() => ({}))) as { + sessionId?: string; + failureId?: string; + }; + if (!sessionId || !failureId) error(400, 'sessionId and failureId required'); + const session = dismissFailure(sessionId, failureId); + if (!session) error(404, 'session not found'); + return json({ session }); +}; diff --git a/web/src/routes/api/session/location/+server.ts b/web/src/routes/api/session/location/+server.ts new file mode 100644 index 0000000..d91b75d --- /dev/null +++ b/web/src/routes/api/session/location/+server.ts @@ -0,0 +1,14 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { updateSessionLocation } from '$lib/server/sessions'; + +export const POST: RequestHandler = async ({ request }) => { + const { sessionId, locationId } = (await request.json().catch(() => ({}))) as { + sessionId?: string; + locationId?: number | null; + }; + if (!sessionId) error(400, 'sessionId required'); + const session = updateSessionLocation(sessionId, locationId ?? null); + if (!session) error(404, 'session not found'); + return json({ session }); +}; diff --git a/web/src/routes/api/session/mode/+server.ts b/web/src/routes/api/session/mode/+server.ts new file mode 100644 index 0000000..b318653 --- /dev/null +++ b/web/src/routes/api/session/mode/+server.ts @@ -0,0 +1,18 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { updateSessionMode } from '$lib/server/sessions'; +import type { ScanMode } from '$lib/types'; + +const VALID: ScanMode[] = ['import', 'update', 'get', 'locate']; + +export const POST: RequestHandler = async ({ request }) => { + const { sessionId, mode } = (await request.json().catch(() => ({}))) as { + sessionId?: string; + mode?: ScanMode; + }; + if (!sessionId) error(400, 'sessionId required'); + if (!mode || !VALID.includes(mode)) error(400, 'valid mode required'); + const session = updateSessionMode(sessionId, mode); + if (!session) error(404, 'session not found'); + return json({ session }); +}; diff --git a/web/src/routes/api/session/retry/+server.ts b/web/src/routes/api/session/retry/+server.ts new file mode 100644 index 0000000..d84e68b --- /dev/null +++ b/web/src/routes/api/session/retry/+server.ts @@ -0,0 +1,21 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { retryFailure, getSession, type RetryFixes } from '$lib/server/sessions'; + +export const POST: RequestHandler = async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as { + sessionId?: string; + failureId?: string; + fixes?: RetryFixes; + }; + if (!body.sessionId) error(400, 'sessionId required'); + if (!body.failureId) error(400, 'failureId required'); + + try { + const outcome = await retryFailure(body.sessionId, body.failureId, body.fixes ?? {}); + return json({ outcome, session: getSession(body.sessionId) }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + error(400, msg); + } +}; diff --git a/web/src/routes/api/session/scan/+server.ts b/web/src/routes/api/session/scan/+server.ts new file mode 100644 index 0000000..dc3ceb4 --- /dev/null +++ b/web/src/routes/api/session/scan/+server.ts @@ -0,0 +1,30 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { processScan, getSession } from '$lib/server/sessions'; + +export const POST: RequestHandler = async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as { + sessionId?: string; + rawBarcode?: string; + partCode?: string | null; + quantity?: number | null; + locationId?: number | null; + partId?: number | null; + }; + if (!body.sessionId) error(400, 'sessionId required'); + if (body.rawBarcode == null) error(400, 'rawBarcode required'); + + try { + const outcome = await processScan(body.sessionId, { + rawBarcode: body.rawBarcode, + partCode: body.partCode ?? null, + quantity: body.quantity ?? null, + locationId: body.locationId ?? null, + partId: body.partId ?? null + }); + return json({ outcome, session: getSession(body.sessionId) }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + error(400, msg); + } +}; diff --git a/web/src/routes/api/session/start/+server.ts b/web/src/routes/api/session/start/+server.ts new file mode 100644 index 0000000..17ba6c5 --- /dev/null +++ b/web/src/routes/api/session/start/+server.ts @@ -0,0 +1,21 @@ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { createSession } from '$lib/server/sessions'; +import type { ScanMode } from '$lib/types'; + +const VALID_MODES: ScanMode[] = ['import', 'update', 'get', 'locate']; + +export const POST: RequestHandler = async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as { + mode?: ScanMode; + locationId?: number | null; + }; + const mode = body.mode ?? 'import'; + if (!VALID_MODES.includes(mode)) error(400, `invalid mode: ${mode}`); + + const session = createSession({ + mode, + locationId: body.locationId ?? null + }); + return json({ session }); +}; diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..b4b7de8 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-node'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..bf699a8 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,7 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()] +});